mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 13:12:00 -05:00
Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f71239e14 | ||
|
|
8fd7da09a5 | ||
|
|
4b6611afaa | ||
|
|
3f484f77d0 | ||
|
|
9275b34622 | ||
|
|
94481b83b6 | ||
|
|
d61720817e | ||
|
|
d30fbb1afd | ||
|
|
1a7a94d301 | ||
|
|
d809654e3c | ||
|
|
14350c31a5 | ||
|
|
3d06ee685a | ||
|
|
7feb44caf9 | ||
|
|
53ed0095e1 | ||
|
|
2d49cb4508 | ||
|
|
458c25a6c5 | ||
|
|
2910415458 | ||
|
|
795b12ee2a | ||
|
|
5b5b10cde6 | ||
|
|
bb7b6447ae | ||
|
|
16d1b628a7 | ||
|
|
330f9c8db8 | ||
|
|
c34c8d0a0a | ||
|
|
f314695267 | ||
|
|
d4cd8c3300 | ||
|
|
e04bfc3182 | ||
|
|
02ec827c72 | ||
|
|
0953ce40dd | ||
|
|
72b9d6d14e | ||
|
|
9a941507f4 | ||
|
|
b0d1af05df | ||
|
|
8b26cfd299 | ||
|
|
7c05cd66ea | ||
|
|
3483975b90 | ||
|
|
f76c11333b | ||
|
|
78c56dac8d | ||
|
|
8ac2f1b3af | ||
|
|
12da90a7a7 | ||
|
|
d14267994a | ||
|
|
ccafe483d8 | ||
|
|
818d432cc8 | ||
|
|
f1ae627854 | ||
|
|
b4d23ab1f6 | ||
|
|
81e67cfcee | ||
|
|
eedc6f0f92 | ||
|
|
27854cf738 | ||
|
|
e753dd5966 | ||
|
|
c630c002dc | ||
|
|
d141c8fa7d | ||
|
|
5c3e7e6560 | ||
|
|
304bef7909 | ||
|
|
050e89e44a | ||
|
|
8dbc69fa73 | ||
|
|
b52c25582b | ||
|
|
edcd580f0e | ||
|
|
2a1cc76ece | ||
|
|
30fa7279e6 | ||
|
|
c7cfe66797 | ||
|
|
4d76a8fdb5 | ||
|
|
9980dd4d22 | ||
|
|
dd169dccbd | ||
|
|
ddb72de88c | ||
|
|
d607c61e56 | ||
|
|
24534161b9 | ||
|
|
86bac02f66 | ||
|
|
35ae1b3869 | ||
|
|
a6a3fd1268 | ||
|
|
a58dc776c3 | ||
|
|
0324f37d91 | ||
|
|
c6625657a6 | ||
|
|
31831a74e5 | ||
|
|
7684804d94 | ||
|
|
e6039e18f3 | ||
|
|
6a11094c05 | ||
|
|
051d6672ae | ||
|
|
8e716fa2c1 | ||
|
|
833e180fad | ||
|
|
c38db32ee7 | ||
|
+5 |
b08c6c2389 | ||
|
|
f743ff51d0 | ||
|
|
e8f25a1673 | ||
|
|
59cd9ef7f0 | ||
|
|
4e6f044407 | ||
|
|
3167aba55c | ||
|
|
96c11c7373 | ||
|
|
682b9257f5 | ||
|
|
e26e904962 | ||
|
|
b326c22854 | ||
|
|
c07ddc4157 | ||
|
|
949e2687fc | ||
|
|
fa5d5d5f8e | ||
|
|
3b529777a8 | ||
|
|
88e37c0667 | ||
|
|
afd84ea35a | ||
|
|
cdbc7a35f7 | ||
|
|
a18f268547 | ||
|
|
28edf9ce0e | ||
|
|
e99b22349d | ||
|
|
9775cc03fd | ||
|
|
18333e21fd | ||
|
|
ad3d184811 | ||
|
|
d47c2d9c0d | ||
|
|
91b67a395e | ||
|
|
04e4324981 | ||
|
|
c1c0f7a25b | ||
|
|
df9e7571a1 | ||
|
|
94d6045a22 | ||
|
|
44edfcafa1 | ||
|
|
be2e78f729 | ||
|
|
c22fc59035 | ||
|
|
29b631657f | ||
|
|
ad82de28e8 | ||
|
|
fea07ef202 | ||
|
|
0481052308 | ||
|
|
2f024c3fee | ||
|
|
87d241d166 | ||
|
|
092c97f9ae |
No files matched your search
@@ -5,7 +5,7 @@
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "22"
|
||||
"version": "20"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
},
|
||||
|
||||
@@ -5,9 +5,6 @@ on:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright
|
||||
@@ -20,8 +17,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
|
||||
@@ -33,8 +33,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
@@ -88,8 +86,6 @@ jobs:
|
||||
dockerfile: ./_docker/Dockerfile.playwright-previews
|
||||
- name: jwt
|
||||
dockerfile: ./_docker/Dockerfile.playwright-jwt
|
||||
- name: performance
|
||||
dockerfile: ./_docker/Dockerfile.playwright-performance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -126,20 +122,6 @@ jobs:
|
||||
type=gha,scope=main-playwright-base
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
|
||||
- name: Extract listing performance results
|
||||
if: matrix.name == 'performance' && success()
|
||||
run: |
|
||||
mkdir -p perf-artifact
|
||||
cid=$(docker create "${{ steps.build.outputs.imageid }}")
|
||||
docker cp "$cid:/app/frontend/test-results/listing-performance-perf/results.json" perf-artifact/results.json
|
||||
docker rm "$cid"
|
||||
- name: Upload listing performance results
|
||||
if: matrix.name == 'performance' && success()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: listing-performance-results
|
||||
path: perf-artifact/results.json
|
||||
retention-days: 7
|
||||
push_pr_to_registry:
|
||||
name: Build docker image
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Promote Beta to Stable
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to promote (format: 0.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
promote-beta-to-stable:
|
||||
name: Promote Beta to Stable
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }} # Uses Personal Access Token for branch operations
|
||||
|
||||
- name: Validate version format
|
||||
id: validate_version
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "❌ ERROR: Version must be in '0.0.0' format."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Version format validated: $VERSION"
|
||||
|
||||
- name: Check if beta branch exists
|
||||
id: check_beta_branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
|
||||
if ! git ls-remote --exit-code origin "$BETA_BRANCH"; then
|
||||
echo "❌ ERROR: Beta branch '$BETA_BRANCH' does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Beta branch '$BETA_BRANCH' exists."
|
||||
|
||||
- name: Check if stable branch already exists
|
||||
id: check_stable_branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
if git ls-remote --exit-code origin "$STABLE_BRANCH"; then
|
||||
echo "❌ ERROR: Stable branch '$STABLE_BRANCH' already exists!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Stable branch '$STABLE_BRANCH' does not exist."
|
||||
|
||||
- name: Create and push new stable branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
# Fetch latest changes
|
||||
git fetch origin "$BETA_BRANCH"
|
||||
|
||||
# Create new stable branch from beta branch
|
||||
git checkout -b "$STABLE_BRANCH" origin/"$BETA_BRANCH"
|
||||
|
||||
# Push new stable branch to remote
|
||||
git push origin "$STABLE_BRANCH"
|
||||
|
||||
echo "✅ Successfully created stable branch '$STABLE_BRANCH' from '$BETA_BRANCH'."
|
||||
|
||||
- name: Merge beta into stable
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
# Checkout stable branch
|
||||
git checkout "$STABLE_BRANCH"
|
||||
|
||||
# Merge beta into stable
|
||||
git merge --no-ff "origin/$BETA_BRANCH" -m "Merge $BETA_BRANCH into $STABLE_BRANCH"
|
||||
|
||||
# Push merge changes
|
||||
git push origin "$STABLE_BRANCH"
|
||||
|
||||
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
|
||||
@@ -22,10 +22,11 @@ jobs:
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Lint backend
|
||||
run: |
|
||||
GOLANGCI_LINT="$(cd backend/build && go tool -n golangci-lint)"
|
||||
cd backend && "$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:
|
||||
@@ -40,8 +41,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run lint
|
||||
test-frontend:
|
||||
@@ -49,8 +48,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run test
|
||||
check-translations:
|
||||
@@ -58,8 +55,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run i18n:check
|
||||
check-docs:
|
||||
@@ -76,7 +71,5 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run icons:check
|
||||
@@ -0,0 +1,66 @@
|
||||
name: beta release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "beta/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright - regular
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
create_release_tag:
|
||||
needs: [ test_playwright ]
|
||||
name: Create Release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: |
|
||||
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
|
||||
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
|
||||
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
|
||||
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
|
||||
id: extract_branch
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
prerelease: false
|
||||
make_latest: false
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
@@ -0,0 +1,66 @@
|
||||
name: stable release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "stable/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright - regular
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
create_release_tag:
|
||||
needs: [ test_playwright ]
|
||||
name: Create Release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: |
|
||||
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
|
||||
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
|
||||
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-stable
|
||||
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
|
||||
id: extract_branch
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
prerelease: false
|
||||
make_latest: true
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
@@ -17,8 +17,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Install dependencies and build frontend
|
||||
run: npm i && npm run build
|
||||
working-directory: frontend
|
||||
|
||||
+1
-11
@@ -6,7 +6,6 @@
|
||||
*.bak
|
||||
*.log
|
||||
*.mjs
|
||||
!frontend/scripts/serve-perf-dashboard.mjs
|
||||
_old
|
||||
rice-box.go
|
||||
.idea/
|
||||
@@ -20,11 +19,7 @@ frontend/tsconfig.tsbuildinfo
|
||||
/backend/dist
|
||||
/frontend/dist
|
||||
/frontend/pkg
|
||||
/frontend/.cache
|
||||
/frontend/test-results
|
||||
# Playwright's default HTML report output dir (written when a config enables the
|
||||
# "html" reporter). Generated output, never committed.
|
||||
/frontend/playwright-report
|
||||
/frontend/playwright-files
|
||||
/frontend/screenshots
|
||||
/frontend/generated
|
||||
@@ -32,11 +27,6 @@ frontend/.venv-icons
|
||||
frontend/.tmp-icons-*
|
||||
/frontend/tests/playwright-files/demo*
|
||||
/frontend/loginAuth.json
|
||||
/frontend/tests/playwright/performance/results.json
|
||||
# Generated perf report artifacts. NOTE: perf-baseline.json is intentionally NOT
|
||||
# ignored — it is the committed chromium baseline that CI gates against.
|
||||
/frontend/tests/playwright/performance/report.json
|
||||
/frontend/tests/playwright/performance/report.md
|
||||
/frontend/sharePrepStorage.json
|
||||
/backend/vendor
|
||||
/backend/*.cov
|
||||
@@ -68,7 +58,7 @@ yarn-error.log*
|
||||
*.sln
|
||||
*.sw*
|
||||
bin/
|
||||
/build/
|
||||
build/
|
||||
*__debug*
|
||||
backend/custom.css
|
||||
|
||||
|
||||
-141
@@ -2,147 +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.8
|
||||
|
||||
**Security**:
|
||||
- [Critical] A forged JWT could authenticate as any known `belongsTo`. Auth signing keys are now persisted in the application database and JWT validation fails closed when no key is configured. (GHSA-8f9r-wg7w-pfw) (#2987) Thanks @d3do-23 and @whoamis3c.
|
||||
- [Medium] Public upload shares with replacements disabled (`allowReplacements=false`) now reject overwrites when clients send `override=true`. (GHSA-3846-gh75-gp3m) Thanks @d3do-23
|
||||
- [Medium] OnlyOffice document-server callbacks now verify the shared `integrations.office.secret` JWT (HS256), reject unsigned callback bodies when a secret is configured, and require the callback document key to match the cached editor session for the target path (fail closed on cache miss or path lookup failure).
|
||||
|
||||
**New Features**:
|
||||
- add "Upload only what's missing" to the upload conflict prompt (#2985) (#2553)
|
||||
|
||||
**Notes**:
|
||||
- Improved UI responsiveness for larger directories and firefox, marginal improvement to memory (#1773) (#2879)
|
||||
- `/api/resources/download` and `/public/api/resources/download` now return HTTP 404 when the requested file or directory does not exist, instead of 500 (#2981)
|
||||
- `GET /api/resources` returns 400 when the `path` query parameter is missing or empty instead of 500 (#2801)
|
||||
|
||||
**Bugfixes**:
|
||||
- Public share folder and multi-file ZIP downloads were empty for anonymous visitors when the source used deny-by-default or path access rules (#2631) (#2365)
|
||||
- 401 on pdf download button action (#2978)
|
||||
- OnlyOffice saves could no-op with HTTP 200 when JWT was enabled: callback POST bodies wrapped in `token` were ignored and JWT parsing omitted fields such as `url` required to download the updated document from the Document Server.
|
||||
|
||||
## v2.0.7
|
||||
|
||||
**New Features**:
|
||||
- Add cli init CLI command (#2957)
|
||||
|
||||
**Notes**:
|
||||
- Pop-up preview has 200ms debounce delay so it doesn't flash from moving the cursor across files quickly.
|
||||
- Added risc-v to official releases
|
||||
|
||||
**Bugfixes**:
|
||||
- Hide the Replace option on upload/create conflict prompts when the user lacks modify permission (or when a public share disallows replacements), so create-only users are not offered an action that the server rejects (#2837)
|
||||
- Preserve Ctrl-click file selection when keyboard state is stale (#2958) (#2923)
|
||||
- Avoid false stalls during parallel transfers (#2950) (#2948) thanks @gudcks0305
|
||||
- Cap source usage-bar percentage at 100% when indexed size exceeds partition total (#2761) (#2238)
|
||||
- On Linux, source partition totals sum distinct filesystems mounted under the source root (nested mounts) so usage bars match indexed content (#2761)
|
||||
|
||||
## v2.0.6
|
||||
|
||||
**Security**:
|
||||
- Share download links no longer create links with token, instead they link to the UI prompting for password before download. If a direct download is required, the `/api/share/direct` api exists and documented by swagger. (#2888)
|
||||
|
||||
**Notes**:
|
||||
- renaming sources updates sidebar links (#2878)
|
||||
- disabling/deleting a user source removes that source from sidebar links (#2942)
|
||||
|
||||
**Bugfixes**:
|
||||
- Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2894)
|
||||
- Support non-ASCII share passwords (#2933)
|
||||
- Fall back to buffered copies when FUSE rejects fast paths (#2938) (#2924)
|
||||
- Preserve deleted sidebar links across restarts (#2935)
|
||||
|
||||
## v2.0.5
|
||||
|
||||
**New Features**:
|
||||
- Users can set default view mode and thumbnail size from Profile settings (Listing options). Admins can set the same defaults for existing users from the user edit prompt. Values persist to the account and apply on new browsers or devices; they do not override view preferences already saved locally ([#2884](https://github.com/gtsteffaniak/filebrowser/issues/2884)).
|
||||
- Admins can edit all profile preference defaults for existing users from the user management panel (listing, preview, sidebar, theme, and related options), matching what was previously available only for new users and global user defaults.
|
||||
|
||||
**Security**:
|
||||
- [Moderate] public metadata api returns file content to anonymous share visitors, ignoring the share's download limit and file-viewer setting (GHSA-55mw-cwg7-m8f5) -- thanks @kta1kri
|
||||
|
||||
**Bugfixes**:
|
||||
- Fixed infinite loading spinner on `/` and `/login` after CSP security hardening ([#2886](https://github.com/gtsteffaniak/filebrowser/issues/2886), [#2890](https://github.com/gtsteffaniak/filebrowser/issues/2890))
|
||||
- fixed darkmode styling bug where background wouldn't respect configured settings when not logged in
|
||||
- fixed styling bug where padding was missing from right side of listing view.
|
||||
- tilde paths for sources were not properly expanded. (eg "~" for home)
|
||||
- fixed tooltip not showing up for some circumstances on mobile, improved consistency.
|
||||
- a few styling fixes for inconsistencies (#2908)
|
||||
- Disk-usage widget no longer reports PB/TB-scale garbage on Docker Desktop for Mac (virtiofs) bind mounts; space is computed from the filesystem fragment size (`f_frsize`) instead of the optimal-transfer block size (`f_bsize`) on Linux ([#2894](https://github.com/gtsteffaniak/filebrowser/issues/2894)).
|
||||
|
||||
## v2.0.4
|
||||
|
||||
**Bugfixes**:
|
||||
- OnlyOffice editor failed to load after the CSP security fix; allow configured `integrations.office.url` and `integrations.office.internalUrl` origins in `script-src` ([#2874](https://github.com/gtsteffaniak/filebrowser/issues/2874)).
|
||||
|
||||
## v2.0.3
|
||||
|
||||
**Security**:
|
||||
- [High] Stored XSS via HTML preview: strip `<script>` from sanitized srcdoc, remove `allow-same-origin` from the preview iframe sandbox, set HttpOnly on the session cookie, and add a nonce-based `script-src` CSP on the SPA shell (GHSA-vvm6-jwrf-hgmg) -- thanks @qrn12580
|
||||
- [Moderate] Non-admin users could PATCH privileged fields on their own account (scopes, permissions, etc.), allowing privilege escalation; restores v1 non-admin field guard on PATCH /api/users (GHSA-p5cc-4p84-c4m2) -- thanks @Chri6s
|
||||
|
||||
**New Features**:
|
||||
- Added option to globally disable the "Install App" message via `frontend.disablePWAInstall`
|
||||
- PWA improvements for installed mobile apps: dedicated maskable icons (192/512), manifest and splash colors that follow the instance default theme, runtime `theme-color` sync on dark-mode toggle, and edge-to-edge safe-area layout for notched devices (#2625) (#2869) -- thanks @APatenaude
|
||||
|
||||
**Bugfixes**:
|
||||
- Fixed Fuji `.raf` thumbnail preview by extracting the camera-embedded JPEG from the RAF header (regression for files where TIFF-based raw extraction does not apply).
|
||||
- Image previews for unsupported decodable formats now return HTTP 415 immediately instead of attempting JPEG decode and returning HTTP 500.
|
||||
- OIDC: preserve the verified ID-token user identifier when falling back to the UserInfo endpoint for missing groups, so login no longer fails with HTTP 500 when UserInfo returns groups but omits the configured identifier.
|
||||
- LDAP: restore `memberOf` as the default `groupsClaim` when unset, fixing group-based admin and login authorization for existing LDAP configs after the v2.0.2 default changed to `groups`.
|
||||
- "Install app" message reappears after being cleared on device.
|
||||
- Restored upload chunk size `0` to disable chunking as documented ([#2202](https://github.com/gtsteffaniak/filebrowser/issues/2202)).
|
||||
- Fixed iOS 26 / WebKit multi-chunk upload stall by isolating chunk connections and returning partial chunk JSON responses ([#2734](https://github.com/gtsteffaniak/filebrowser/issues/2734)).
|
||||
- Sidebar folder links with custom names, icons, or styles no longer disappear after restart; multiple shortcuts to different folders on the same source are preserved ([#2809](https://github.com/gtsteffaniak/filebrowser/issues/2809)).
|
||||
- Adding a source to a user via scopes now auto-adds a matching sidebar link; removing a source keeps the link (shown disabled) so users can delete it manually.
|
||||
- A password reset via CLI returns user to a password method user.
|
||||
- Authentication login methods are consistently enforced without modifying existing account settings.
|
||||
|
||||
## v2.0.2
|
||||
|
||||
**Bugfixes**:
|
||||
- OIDC: `groupsClaim` value is always included in requested scopes and falls back to the UserInfo endpoint when the ID token omits the groups claim.
|
||||
- Windows: fix backslash duplication in navigation URLs, folder sizes showing 4 KB, and download/preview failures (#2815, #2816)
|
||||
- Wrong extension in the 'new database was created popup (#2817) (#2824)
|
||||
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
|
||||
- OnlyOffice is inaccessible on password-protected shares (#2811)
|
||||
- FFmpeg 9.0 incorrectly detected as below minimum 5.0.0 on Windows (#2820) -- thanks @yzxcj797
|
||||
|
||||
## v2.0.1
|
||||
|
||||
**Security**:
|
||||
- [High] Symlink following on read paths no longer escapes source or user/share scope (GHSA-mgqf-5mf5-prfj) -- thanks @je-lv @KasperBuilds
|
||||
- [Moderate] Absolute paths in API path/file parameters could bypass user scope and read files outside the source mount (GHSA-rqqq-wv83-rp74) -- thanks @Wei-Leo
|
||||
- [Moderate] Logout did not invalidate session JWTs with equivalent Base64URL spellings (GHSA-8m35-wcjh-95q7) -- thanks @corbanvilla @soh3e @dderpym (This vulnerability was discovered as part of a U.C. Berkeley security research project by: Corban Villa, Sohee Kim, and Austin Chu)
|
||||
- [Moderate] Stored XSS via unsanitized DOCX hyperlink in DocViewer (GHSA-9wm6-jcjh-3m8c) -- thanks @karen93shieh @je-lv @EclipsSec
|
||||
- [Moderate] Revoked JWTs could still authenticate on public-share and withOrWithoutUser routes until natural expiry, bypassing logout and Api-permission revocation on that surface (GHSA-4wmj-rq3c-m65v) -- thanks @hypnguyen1209
|
||||
|
||||
**Notes**:
|
||||
- GroupMap `SyncUserGroups` fix so JWT/OIDC/LDAP group memberships survive restart (#2742).
|
||||
- Session renew is handled by client keep-alive. removed per-request `X-Renew-Token` header handling.
|
||||
- Improvements to document thumbnail generation performance.
|
||||
- behavior changes for typing to select files in listing view.
|
||||
- next/previous buttons don't hide automatically on photos (#2767)
|
||||
- added more actions available in advanced search (#2776)
|
||||
|
||||
**Bugfixes**:
|
||||
- GroupMap mutations (`SyncUserGroups`, add/remove group members) are write-through to SQL so JWT/OIDC/LDAP group memberships survive restart (#2742).
|
||||
- `defaultEnabled` behavior change/fix so defaultEnabled means its always added to users on startup and login. (updated docs too)
|
||||
- Long uploads/downloads no longer lose the session mid-transfer: proactive session JWT keep-alive renews before expiry (#2638).
|
||||
- Disable auto logout/session expiry if there are active file transfers going (#2638)
|
||||
- upload options get cut off on mobile (#2685)
|
||||
- Uploading image: image gets cut-off (#2765)
|
||||
- Process aborts (not just 500) during PDF thumbnail generation (#2763)
|
||||
- PDF preview generation prevents file uploads from completing (#2752)
|
||||
- Brings back double-tap to zoom images that was mistakenly removed.
|
||||
- multiple embedded subtitles with same language bug (#2756)
|
||||
- gallery view download button missing (#2767)
|
||||
- fixed HEIC rotation regression from v1.5.x
|
||||
- fixed raw image preview regression from v1.5.x
|
||||
- html viewer takes full height
|
||||
- Members without download permission receive 403 when opening text-based files despite OnlyOffice preview being enabled (#2777)
|
||||
|
||||
## v2.0.0
|
||||
|
||||
This version represents the most significant change to date. It **requires** both a database migration and config structural changes. See the [migration guide](https://filebrowserquantum.com/en/docs/getting-started/v2/migration/) for step-by-step upgrade instructions, [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) for a full summary, and the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert legacy config.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
|
||||
- **Go 1.25+** (see `backend/go.mod`)
|
||||
- **Node.js 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
|
||||
- **Docker** (optional, for containerized development)
|
||||
- **Git** especially important on Windows -- needed for bash support.
|
||||
- **Git** especially improtant on windows -- needed for bash support.
|
||||
|
||||
### Optional Tools
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
## Pinned
|
||||
|
||||
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
|
||||
|
||||
:pushpin: [v2.0.0 is now in beta!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/)
|
||||
|
||||
## About
|
||||
|
||||
+2
-3
@@ -5,7 +5,6 @@ ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add --no-cache gcc musl-dev
|
||||
RUN go mod download && go run ./scripts/setup-gofitz-cgo
|
||||
ENV CGO_ENABLED=1
|
||||
RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
|
||||
@@ -14,11 +13,11 @@ RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \
|
||||
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /build
|
||||
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
|
||||
COPY ./frontend/package.json ./frontend/
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN cd frontend && npm ci --maxsockets 1
|
||||
RUN cd frontend && npm i --maxsockets 1
|
||||
COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm run build:docker
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
FROM node:22-slim
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package-lock.json /tmp/package-lock.json
|
||||
RUN PW_VERSION=$(node -p "require('/tmp/package-lock.json').packages['node_modules/@playwright/test'].version") \
|
||||
&& npm i "@playwright/test@${PW_VERSION}" oauth2-mock-server @types/node
|
||||
RUN npm i @playwright/test oauth2-mock-server @types/node
|
||||
RUN npx playwright install --with-deps chromium
|
||||
@@ -1,12 +0,0 @@
|
||||
# Local listing-performance only (see docker-compose local-playwright-performance).
|
||||
# CI uses ghcr.io/gtsteffaniak/playwright-base:chromium + Dockerfile.playwright-performance.
|
||||
FROM node:22-slim
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package-lock.json /tmp/package-lock.json
|
||||
RUN PW_VERSION=$(node -p "require('/tmp/package-lock.json').packages['node_modules/@playwright/test'].version") \
|
||||
&& npm i "@playwright/test@${PW_VERSION}"
|
||||
|
||||
ARG PLAYWRIGHT_BROWSERS=chromium,firefox,webkit
|
||||
ENV PLAYWRIGHT_BROWSERS=${PLAYWRIGHT_BROWSERS}
|
||||
RUN bash -ec 'set -euo pipefail; for b in ${PLAYWRIGHT_BROWSERS//,/ }; do npx playwright install --with-deps "$b"; done'
|
||||
@@ -1,52 +0,0 @@
|
||||
# CI: same ghcr.io playwright-base:chromium + GHA cache as screenshots/previews (chromium only).
|
||||
# Local all-browser runs: make playwright-perf-base + compose PLAYWRIGHT_BASE_IMAGE.
|
||||
ARG PLAYWRIGHT_BASE_IMAGE=ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
FROM ${PLAYWRIGHT_BASE_IMAGE} AS perf
|
||||
WORKDIR /app
|
||||
|
||||
COPY [ "./_docker/src/performance/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
COPY [ "./frontend/playwright.performance.config.ts", "./playwright.config.ts" ]
|
||||
|
||||
# The base image's /app/frontend/package.json is generated by `npm i` and has no
|
||||
# "type" field, so /app/frontend is a CommonJS package. Playwright transpiles the
|
||||
# suite accordingly (emitting `exports.x = ...`), but its ESM loader reparses these
|
||||
# files as ES modules because their syntax is unambiguous ESM. The result is
|
||||
# "ReferenceError: exports is not defined in ES module scope" before a single test
|
||||
# runs. The perf harness is real ESM, so declare it as such — this matches
|
||||
# frontend/package.json in the repo (also "type": "module").
|
||||
RUN node -e 'const fs=require("fs");const p="package.json";const j=JSON.parse(fs.readFileSync(p,"utf8"));j.type="module";fs.writeFileSync(p,JSON.stringify(j,null,2)+"\n")'
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
|
||||
RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
ENV CI=true
|
||||
ENV PERF_BASE_URL=http://127.0.0.1/
|
||||
ENV PERF_SCALES=100,1000,10000
|
||||
ENV PERF_WORKERS=6
|
||||
ENV PERF_TEST_TIMEOUT_MS=480000
|
||||
# CI runs a single pass per scenario (N=1) for fast PR feedback; local runs
|
||||
# default to N=3 and take the median. The baseline records the worker count and
|
||||
# this setting, so an incomparable comparison is reported rather than silently
|
||||
# producing a bogus regression.
|
||||
ENV PERF_REPEATS=1
|
||||
# Stamped into the baseline environment fingerprint so a mismatch is detectable.
|
||||
ENV PERF_IMAGE_TAG=playwright-base-chromium
|
||||
# Lets the harness report container-specific remedies (e.g. rebuild the base
|
||||
# image) instead of host-oriented advice when a browser is missing.
|
||||
ENV PERF_IN_CONTAINER=1
|
||||
|
||||
# CI / make test-playwright-performance (chromium only — same base as other Playwright images)
|
||||
FROM perf AS ci
|
||||
ENV PERF_BROWSERS=chromium
|
||||
# The committed baseline (frontend/tests/playwright/performance/perf-baseline.json)
|
||||
# is copied in by the `COPY ./frontend/tests/` layer above. This step exits
|
||||
# non-zero when a gated metric regresses beyond its tolerance, which is what
|
||||
# makes the performance check gate the PR.
|
||||
RUN ./filebrowser & sleep 5 && cd ../frontend && npx playwright test -c playwright.config.ts
|
||||
@@ -13,4 +13,4 @@ RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
|
||||
@@ -11,11 +11,11 @@ RUN go build -tags analytics -ldflags="-w -s \
|
||||
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /build
|
||||
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
|
||||
COPY ./frontend/package.json ./frontend/
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN cd frontend && npm ci
|
||||
RUN cd frontend && npm i
|
||||
COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm run build:docker
|
||||
|
||||
@@ -14,42 +14,6 @@ services:
|
||||
build:
|
||||
dockerfile: ./_docker/Dockerfile.playwright-screenshots
|
||||
context: ../
|
||||
local-playwright-performance:
|
||||
container_name: local-playwright-performance
|
||||
working_dir: /app/backend
|
||||
volumes:
|
||||
- ../frontend/test-results/playwright-performance:/app/frontend/test-results/playwright-performance
|
||||
- ../frontend/test-results/listing-performance-perf:/app/frontend/test-results/listing-performance-perf
|
||||
- ../frontend/tests/playwright/performance:/out/report
|
||||
environment:
|
||||
PERF_RESULTS_DIR: /app/frontend/test-results/listing-performance-perf
|
||||
PERF_REPORT_DIR: /out/report
|
||||
PERF_GREP: ${PERF_GREP:-}
|
||||
PERF_BROWSERS: ${PERF_BROWSERS:-}
|
||||
# Local runs repeat each scenario and take the median to average out noise.
|
||||
# CI overrides this to 1 for fast feedback.
|
||||
PERF_REPEATS: ${PERF_REPEATS:-3}
|
||||
# Set to 1 to rewrite the committed chromium baseline from this run.
|
||||
PERF_UPDATE_BASELINE: ${PERF_UPDATE_BASELINE:-0}
|
||||
# Local comparisons are advisory unless explicitly enforced.
|
||||
PERF_ENFORCE_BASELINE: ${PERF_ENFORCE_BASELINE:-0}
|
||||
PERF_IMAGE_TAG: ${PERF_IMAGE_TAG:-local}
|
||||
PERF_IN_CONTAINER: "1"
|
||||
# When PERF_UPDATE_BASELINE=1, write through the mounted report dir (host:
|
||||
# frontend/tests/playwright/performance/perf-baseline.json).
|
||||
PERF_BASELINE_PATH: /out/report/perf-baseline.json
|
||||
entrypoint:
|
||||
[
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"./filebrowser & sleep 5 && cd ../frontend && npx playwright test -c playwright.config.ts $${PERF_GREP:+-g \"$$PERF_GREP\"}",
|
||||
]
|
||||
build:
|
||||
dockerfile: ./_docker/Dockerfile.playwright-performance
|
||||
target: perf
|
||||
context: ../
|
||||
args:
|
||||
PLAYWRIGHT_BASE_IMAGE: ${PLAYWRIGHT_BASE_IMAGE:-filebrowser-playwright-perf-base:latest}
|
||||
nginx-proxy-auth:
|
||||
image: nginx
|
||||
container_name: nginx-proxy-auth
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
server:
|
||||
<<: *server-main
|
||||
http:
|
||||
<<: *http-main
|
||||
frontend:
|
||||
<<: *frontend-main
|
||||
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
models: true
|
||||
office: true
|
||||
highQuality: true
|
||||
account:
|
||||
permissions:
|
||||
share: true
|
||||
@@ -1,13 +0,0 @@
|
||||
frontend-main: &frontend-main
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
customThemes:
|
||||
no-rounded:
|
||||
description: "No rounded corners" # The description of the theme to display in the UI.
|
||||
css: "no-rounded.css" # The css file path and filename to use for the theme.
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
@@ -1,24 +0,0 @@
|
||||
http-main: &http-main
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
server-main: &server-main
|
||||
logging:
|
||||
- levels: "warning|error"
|
||||
apiLevels: disabled
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
defaultPermissions:
|
||||
view: true
|
||||
download: true
|
||||
modify: true
|
||||
create: true
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
rules:
|
||||
- folderPath: "/"
|
||||
viewable: true
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -10,7 +9,7 @@ import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig<PlaywrightFixtureOptions>({
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/screenshots-setup.ts",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/screenshots",
|
||||
|
||||
@@ -2,7 +2,6 @@ root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
|
||||
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
|
||||
entrypoint = "./tmp/filebrowser"
|
||||
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
|
||||
|
||||
@@ -2,8 +2,7 @@ root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
|
||||
cmd = "go build -o ./tmp/filebrowser.exe --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
|
||||
cmd = "go build -o ./tmp/filebrowser.exe --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
|
||||
entrypoint = "./tmp/filebrowser.exe"
|
||||
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser.exe -c test_config.yaml"
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
project_name: filebrowser
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
- go run ./scripts/setup-gofitz-cgo
|
||||
|
||||
builds:
|
||||
# Build configuration for linux amd64
|
||||
- id: linux-amd64
|
||||
@@ -49,22 +44,6 @@ builds:
|
||||
post:
|
||||
- upx {{ .Path }}
|
||||
|
||||
# Build configuration for linux riscv64
|
||||
- id: linux-riscv64
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
env:
|
||||
- CC=zig cc -target riscv64-linux-musl
|
||||
# go-fitz does not provide riscv64 MuPDF libraries yet: https://github.com/gen2brain/go-fitz/tree/master/libs
|
||||
tags:
|
||||
- analytics
|
||||
- musl
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- riscv64
|
||||
|
||||
# Build configuration for linux
|
||||
- id: linux-arm32
|
||||
ldflags: *ldflags
|
||||
@@ -87,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
|
||||
@@ -115,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
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
module github.com/gtsteffaniak/filebrowser/backend/build
|
||||
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
4d63.com/gocheckcompilerdirectives v1.4.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
|
||||
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.2.0 // indirect
|
||||
dev.gaijin.team/go/golib v0.8.1 // indirect
|
||||
github.com/4meepo/tagalign v1.4.4 // indirect
|
||||
github.com/Abirdcfly/dupword v0.1.8 // indirect
|
||||
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
|
||||
github.com/AlwxSin/noinlineerr v1.0.6 // indirect
|
||||
github.com/Antonboom/errname v1.1.2 // indirect
|
||||
github.com/Antonboom/nilnil v1.1.2 // indirect
|
||||
github.com/Antonboom/testifylint v1.6.4 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
|
||||
github.com/Djarvur/go-err113 v0.1.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.5.0 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.67.4 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.4.0 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
|
||||
github.com/alexkohler/prealloc v1.1.0 // indirect
|
||||
github.com/alfatraining/structtag v1.0.0 // indirect
|
||||
github.com/alingse/asasalint v0.0.11 // indirect
|
||||
github.com/alingse/nilnesserr v0.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.4 // indirect
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
|
||||
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||
github.com/bep/golibsass v1.2.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.25.0 // indirect
|
||||
github.com/bkielbasa/cyclop v1.2.3 // indirect
|
||||
github.com/blizzy78/varnamelen v0.8.0 // indirect
|
||||
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
|
||||
github.com/bombsimon/wsl/v5 v5.9.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/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-20260910203606-6c9e17dc7a16 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/ckaznocha/intrange v0.3.1 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/curioswitch/go-reassign v0.3.0 // indirect
|
||||
github.com/daixiang0/gci v0.14.0 // indirect
|
||||
github.com/dave/dst v0.28.0 // indirect
|
||||
github.com/denis-tingaikin/go-header v0.5.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.8.0 // indirect
|
||||
github.com/ettle/strcase v0.2.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/firefart/nonamedreturns v1.0.8 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/fzipp/gocyclo v0.6.0 // indirect
|
||||
github.com/ghostiam/protogetter v1.0.1 // indirect
|
||||
github.com/go-critic/go-critic v0.15.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.1 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.2 // indirect
|
||||
github.com/go-openapi/spec v1.0.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/loading v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/pools v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
|
||||
github.com/go-toolsmith/astcast v1.1.0 // indirect
|
||||
github.com/go-toolsmith/astcopy v1.1.0 // indirect
|
||||
github.com/go-toolsmith/astequal v1.2.0 // indirect
|
||||
github.com/go-toolsmith/astfmt v1.1.0 // indirect
|
||||
github.com/go-toolsmith/astp v1.1.0 // indirect
|
||||
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-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/godoc-lint/godoc-lint v0.11.4 // indirect
|
||||
github.com/gofrs/flock v0.13.1 // indirect
|
||||
github.com/gohugoio/hashstructure v1.1.0 // indirect
|
||||
github.com/gohugoio/hugo v0.165.0 // indirect
|
||||
github.com/golangci/asciicheck v0.5.0 // indirect
|
||||
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
|
||||
github.com/golangci/go-printf-func-name v0.1.1 // indirect
|
||||
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.13.2 // indirect
|
||||
github.com/golangci/golines v0.15.0 // indirect
|
||||
github.com/golangci/misspell v0.8.0 // indirect
|
||||
github.com/golangci/plugin-module-register v0.1.2 // indirect
|
||||
github.com/golangci/revgrep v0.8.0 // indirect
|
||||
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 // indirect
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
|
||||
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gordonklaus/ineffassign v0.2.0 // indirect
|
||||
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
|
||||
github.com/gostaticanalysis/comment v1.5.0 // indirect
|
||||
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
|
||||
github.com/gostaticanalysis/nilerr v0.1.2 // indirect
|
||||
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/hexops/gotextdiff v1.0.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jgautheron/goconst v1.11.0 // indirect
|
||||
github.com/jjti/go-spancheck v0.6.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
|
||||
github.com/kisielk/errcheck v1.20.0 // indirect
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
|
||||
github.com/kulti/thelper v0.7.1 // indirect
|
||||
github.com/kunwardeep/paralleltest v1.0.15 // indirect
|
||||
github.com/ldez/exptostd v0.4.5 // indirect
|
||||
github.com/ldez/gomoddirectives v0.10.0 // indirect
|
||||
github.com/ldez/grignotin v0.10.1 // indirect
|
||||
github.com/ldez/structtags v0.6.1 // indirect
|
||||
github.com/ldez/tagliatelle v0.8.0 // indirect
|
||||
github.com/ldez/usetesting v0.5.0 // indirect
|
||||
github.com/leonklingele/grouper v1.1.2 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/macabu/inamedparam v0.2.0 // 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-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||
github.com/mgechev/revive v1.16.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/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/nishanths/exhaustive v0.13.0 // indirect
|
||||
github.com/nishanths/predeclared v0.2.2 // indirect
|
||||
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/prometheus/client_golang v1.24.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.3 // indirect
|
||||
github.com/prometheus/common v0.71.0 // indirect
|
||||
github.com/prometheus/procfs v0.22.0 // indirect
|
||||
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
|
||||
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
|
||||
github.com/quasilyte/gogrep v0.5.0 // indirect
|
||||
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
|
||||
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
|
||||
github.com/raeperd/recvcheck v0.3.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // 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/sashamelentyev/interfacebloat v1.1.0 // indirect
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
|
||||
github.com/securego/gosec/v2 v2.29.0 // indirect
|
||||
github.com/sirupsen/logrus v1.10.2 // indirect
|
||||
github.com/sivchari/containedctx v1.0.3 // indirect
|
||||
github.com/sonatard/noctx v0.5.1 // indirect
|
||||
github.com/sourcegraph/go-diff v0.9.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
|
||||
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.16 // indirect
|
||||
github.com/tetafro/godot v1.5.6 // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c // indirect
|
||||
github.com/timonwong/loggercheck v0.12.0 // 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/uudashr/gocognit v1.2.1 // indirect
|
||||
github.com/uudashr/iface v1.5.1 // indirect
|
||||
github.com/xen0n/gosmopolitan v1.3.0 // indirect
|
||||
github.com/xo/terminfo v1.2.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
github.com/yagipy/maintidx v1.0.0 // indirect
|
||||
github.com/yeya24/promlinter v0.3.0 // indirect
|
||||
github.com/ykadowak/zerologlint v0.1.5 // indirect
|
||||
gitlab.com/bosi/decorder v0.4.2 // indirect
|
||||
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.1 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.28.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/tools v0.50.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
honnef.co/go/tools v0.8.1 // indirect
|
||||
mvdan.cc/gofumpt v0.12.0 // indirect
|
||||
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/air-verse/air
|
||||
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
|
||||
github.com/swaggo/swag/cmd/swag
|
||||
)
|
||||
|
||||
// air (via Hugo) and golangci-lint need different github.com/gobwas/glob APIs; pin Hugo 0.164 + glob 0.2.3.
|
||||
exclude (
|
||||
github.com/alecthomas/go-check-sumtype v0.5.0
|
||||
github.com/gobwas/glob v1.0.0
|
||||
github.com/gohugoio/hugo v0.166.0
|
||||
)
|
||||
@@ -1,736 +0,0 @@
|
||||
4d63.com/gocheckcompilerdirectives v1.4.0 h1:ZLq62rbGWVmQhiZ8kuNVIT/M09xCSTdJz9K3xOdT/CY=
|
||||
4d63.com/gocheckcompilerdirectives v1.4.0/go.mod h1:9ZOAiMOjqC/nRwci2fcUXVHUNLG/cH6r6rhUh+jTFtQ=
|
||||
4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU=
|
||||
4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0=
|
||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||
codeberg.org/chavacava/garif v0.2.1 h1:K9oYxSlvlXrHXyW26Z4q61bTpJDo1wbvXcYKar/F/LM=
|
||||
codeberg.org/chavacava/garif v0.2.1/go.mod h1:oHnDSmc0f9K1MeE+MQD/yjkiIB5Xsn5y3S9Dg96Xk84=
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI=
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y=
|
||||
dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI=
|
||||
dev.gaijin.team/go/exhaustruct/v5 v5.2.0 h1:HZB49Fshn8qHJCi9VzYtrcnR1jbLteRHXfnQz8Adams=
|
||||
dev.gaijin.team/go/exhaustruct/v5 v5.2.0/go.mod h1:5IshpqhgGdKIrF8lBmi3M6YKmI7T3hMzAL9nrflko88=
|
||||
dev.gaijin.team/go/golib v0.8.1 h1:JYju4x9BSo+QD/AYeHULVDcvEhiFg8wOi6pT0IaZF5E=
|
||||
dev.gaijin.team/go/golib v0.8.1/go.mod h1:c5fu7t1RSGMxSQgcUYO1sODbzsYnOCXJLmHeNG1Eb+0=
|
||||
github.com/4meepo/tagalign v1.4.4 h1:052JDGj2pBORc6NkD30dDXpsUpoYszDeUwr9/5YZuVA=
|
||||
github.com/4meepo/tagalign v1.4.4/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c=
|
||||
github.com/Abirdcfly/dupword v0.1.8 h1:SrhcUuGsROBuChFxHALRYzyyPODWn9zwghmzPvD9Cd8=
|
||||
github.com/Abirdcfly/dupword v0.1.8/go.mod h1:XZrhVnI7YGpsTiWZANSQaBJ4QpL/Tq5vIEdKJJAs9WI=
|
||||
github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo=
|
||||
github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY=
|
||||
github.com/AlwxSin/noinlineerr v1.0.6 h1:KAvuxunTe9QxvqrFB7nZTdb/7Wzas4AvifslTnG0Ld8=
|
||||
github.com/AlwxSin/noinlineerr v1.0.6/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc=
|
||||
github.com/Antonboom/errname v1.1.2 h1:dxwONZJua3VB8Xh/VaCjqAcqF645sWWv7xj26zy7tdQ=
|
||||
github.com/Antonboom/errname v1.1.2/go.mod h1:YeZIpgLMxT+SNkruGgYkLhzq/9vs3fsolTZegKaKDZI=
|
||||
github.com/Antonboom/nilnil v1.1.2 h1:aNlFuJhaEseXe4fHO3xbjXlSeEiQVYa2lEkWD2s2hAY=
|
||||
github.com/Antonboom/nilnil v1.1.2/go.mod h1:0ynwvphOLmAuMwTNDyBnDZmSwZoDpcFXmUHmzoHH2WA=
|
||||
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
|
||||
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/ClickHouse/clickhouse-go-linter v1.2.1 h1:zGEKIyd5YL08ieWG/LOUmlau2DxbxPVOfAeo+4Jz3ck=
|
||||
github.com/ClickHouse/clickhouse-go-linter v1.2.1/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4=
|
||||
github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g=
|
||||
github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
|
||||
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
|
||||
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
|
||||
github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
|
||||
github.com/air-verse/air v1.67.4 h1:pkWBQues0094VMgUZ9MboNfS1vyCBEXeU6QxuD6/PwU=
|
||||
github.com/air-verse/air v1.67.4/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/go-check-sumtype v0.4.0 h1:gd4mE4QsA62sXlnFIs8GUv8/0xH3DOUqTkYQR1l2v28=
|
||||
github.com/alecthomas/go-check-sumtype v0.4.0/go.mod h1:aYYllI8IXNjo7ZYUzI2+wyLRECMg3zo+8CZj9JTBAWU=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
|
||||
github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M=
|
||||
github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
|
||||
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
|
||||
github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
|
||||
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
|
||||
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
|
||||
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
|
||||
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
|
||||
github.com/andybalholm/brotli v1.2.4 h1:649LN6qF+/7JJYNN3Wdu4Dt5PS6eC/yP/Go3Bb0/AHI=
|
||||
github.com/andybalholm/brotli v1.2.4/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI=
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM=
|
||||
github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM=
|
||||
github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
|
||||
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bep/gitmap v1.9.0 h1:2pyb1ex+cdwF6c4tsrhEgEKfyNfxE34d5K+s2sa9byc=
|
||||
github.com/bep/gitmap v1.9.0/go.mod h1:Juq6e1qqCRvc1W7nzgadPGI9IGV13ZncEebg5atj4Vo=
|
||||
github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
|
||||
github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
|
||||
github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg=
|
||||
github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU=
|
||||
github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI=
|
||||
github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
|
||||
github.com/bep/golocales v0.2.0 h1:4H1H5UPw3ainpj5zykeEfiMRQngyaIC/t+I4Dvn+fvE=
|
||||
github.com/bep/golocales v0.2.0/go.mod h1:Hl78nje8mNL3LzLeJvYN9NsIZgyFJGrGfvgO9r1+mwE=
|
||||
github.com/bep/goportabletext v0.2.0 h1:CZ9f8jADBWqHwBymQiJJPCTSV/tHSA+PYzlUf86Yze0=
|
||||
github.com/bep/goportabletext v0.2.0/go.mod h1:xDeA5+qcgKzJq6Q6XjAiBKtxLD3Yn7f6XP4joD3J3qU=
|
||||
github.com/bep/helpers v0.12.0 h1:tD6V2DQW0B+FUynF2etR/106S/TO9akm+vA/Hk24GxY=
|
||||
github.com/bep/helpers v0.12.0/go.mod h1:PfE7MGdA8sSQ19nyDh4tYbs5rAlStlJaDI21f/fnNps=
|
||||
github.com/bep/imagemeta v1.0.0 h1:8JZmahegn5p1M9CpFmga7LoSxErpu1uFfpSzMUNju7A=
|
||||
github.com/bep/imagemeta v1.0.0/go.mod h1:+Hlp195TfZpzsqCxtDKTG6eWdyz2+F2V/oCYfr3CZKA=
|
||||
github.com/bep/lazycache v0.8.1 h1:ko6ASLjkPxyV5DMWoNNZ8B2M0weyjqXX8IZkjBoBtvg=
|
||||
github.com/bep/lazycache v0.8.1/go.mod h1:pbEiFsZoq7cLXvrTll0AHOPEurB1aGGxx4jKjOtlx9w=
|
||||
github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
|
||||
github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
|
||||
github.com/bep/overlayfs v0.11.0 h1:aymHDGC0CHpvn0XvTfgpK6skCp16oMi+tdUF32l6pPs=
|
||||
github.com/bep/overlayfs v0.11.0/go.mod h1:L+ggdoKm+Y7Xb4a1osd+/LOPG4qsY62snqRqJH5Mspc=
|
||||
github.com/bep/textandbinarywriter v0.1.0 h1:KXmXsRN2Uhwhm1G3e/snM8+5SPQBJrCEpIosdIBR3po=
|
||||
github.com/bep/textandbinarywriter v0.1.0/go.mod h1:dAcHveajlWWU7PXhp6Dn4PIAYDg2H13Huif9xMS2w8w=
|
||||
github.com/bep/tmc v0.6.0 h1:5zWy4L+3gS+Kk8czzLC4g7ETaC3wkX9ZtTRdAdL8V4s=
|
||||
github.com/bep/tmc v0.6.0/go.mod h1:SNHxc3o2WSNMAYqJcAO0rxFY+pbhZzMwjIHe5xaAue0=
|
||||
github.com/bits-and-blooms/bitset v1.25.0 h1:0Ro0qF4abCkM6SqWPVj29sFhAbMPAZpaDD7xhJ10beM=
|
||||
github.com/bits-and-blooms/bitset v1.25.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w=
|
||||
github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo=
|
||||
github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M=
|
||||
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
|
||||
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
|
||||
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
|
||||
github.com/bombsimon/wsl/v5 v5.9.0 h1:WCrgZ7RQnZO5oEwbVTlYgBdU3wL294kR1BSWV8vTfsU=
|
||||
github.com/bombsimon/wsl/v5 v5.9.0/go.mod h1:kjo4HiAV5FDkHC8/uzJq9mBffEEd6WT/nvN7DoMovDM=
|
||||
github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE=
|
||||
github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE=
|
||||
github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg=
|
||||
github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s=
|
||||
github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I=
|
||||
github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4=
|
||||
github.com/butuzov/mirror v1.3.3 h1:v0RsWBhfFc1RQqE/f3sHpSttKDtodFn0gFmtYyD4/hA=
|
||||
github.com/butuzov/mirror v1.3.3/go.mod h1:h9BzzwYnTiHO0GzgvaTqIg7VSsOUhdIv51cHFFBmX1w=
|
||||
github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ=
|
||||
github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc=
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk=
|
||||
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 h1:/hSCZO1puetbGd2JikHKN0nAcb2mFevzxJElFoduhaw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16/go.mod h1:JFvmQPZh3uAu7PpSZU91bcR0BrIy24HhW+0EsW1uGxE=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs=
|
||||
github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs=
|
||||
github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88=
|
||||
github.com/daixiang0/gci v0.14.0 h1:h6AcLqmjIOBgojhtzY2CvBnA6RawPTkBHgtMvYD5YZ8=
|
||||
github.com/daixiang0/gci v0.14.0/go.mod h1:w9E+SWQ4aPQ+xYPUdqitGDoXpT4mayqOfk7Szz2U6wQ=
|
||||
github.com/dave/dst v0.28.0 h1:EIEuRC5XmVKuZF2OeyMahXX9ARVt6Xxr8vI0WtIY7K8=
|
||||
github.com/dave/dst v0.28.0/go.mod h1:C4mfimBeSHfsgskhR/mUYSE05s2ih+PW6g9FP3c49tE=
|
||||
github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
|
||||
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
|
||||
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2/v2 v2.8.0 h1:CekDhPLGfm+GAJmPVFIG+5dqMIQPkyHJll7BbdneDfw=
|
||||
github.com/dlclark/regexp2/v2 v2.8.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
|
||||
github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
|
||||
github.com/evanw/esbuild v0.28.2 h1:A2uETn4jrQTcXaT/shwTDTYBxDjl7fV7nXmUrJxfA2w=
|
||||
github.com/evanw/esbuild v0.28.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
|
||||
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
|
||||
github.com/firefart/nonamedreturns v1.0.8 h1:iB32Dl17zJl1zlVEj/WlUWgx0HiRyQ85OUw1WHa4/II=
|
||||
github.com/firefart/nonamedreturns v1.0.8/go.mod h1:vxFNvm5AfP/8rgAKFzYmnqx0yp1HjrYsErZ9pHPTznA=
|
||||
github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
|
||||
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
|
||||
github.com/getkin/kin-openapi v0.146.0 h1:RA/1RdxrSJW4oc1+6IfnYB6AO9CaGy8GTKPh0k4Ordo=
|
||||
github.com/getkin/kin-openapi v0.146.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
|
||||
github.com/ghostiam/protogetter v1.0.1 h1:q4LLTx2KJHJpxrfO3BniUDWoFkoqWD/UlHhClBlUlLM=
|
||||
github.com/ghostiam/protogetter v1.0.1/go.mod h1:50Fh5yZfs6SFGrd1FlmT04NcguyEOxyjNK5zGD6/EvM=
|
||||
github.com/go-critic/go-critic v0.15.0 h1:MDUZIQKytIl+vPEc0Igw51m4uLTdi1ZGz2jatEqNs1g=
|
||||
github.com/go-critic/go-critic v0.15.0/go.mod h1:3BPrw8nHATGm3o2n37cssOmJzpySGKCdY8JWkCzxgKQ=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg=
|
||||
github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY=
|
||||
github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc=
|
||||
github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs=
|
||||
github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk=
|
||||
github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0=
|
||||
github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q=
|
||||
github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k=
|
||||
github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM=
|
||||
github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY=
|
||||
github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI=
|
||||
github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c=
|
||||
github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc=
|
||||
github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk=
|
||||
github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI=
|
||||
github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I=
|
||||
github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE=
|
||||
github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM=
|
||||
github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs=
|
||||
github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8=
|
||||
github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8=
|
||||
github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU=
|
||||
github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s=
|
||||
github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw=
|
||||
github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4=
|
||||
github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ=
|
||||
github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw=
|
||||
github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY=
|
||||
github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco=
|
||||
github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4=
|
||||
github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA=
|
||||
github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA=
|
||||
github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk=
|
||||
github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus=
|
||||
github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8=
|
||||
github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw=
|
||||
github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
|
||||
github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
|
||||
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
||||
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
|
||||
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godoc-lint/godoc-lint v0.11.4 h1:N9zUYVJ/OkznBbrBsU/zODvua64EUplMBLQWStO3+1M=
|
||||
github.com/godoc-lint/godoc-lint v0.11.4/go.mod h1:7usTXoxS0QYIi5usTSCPwR0w/6xLjcz450shXjLn3t4=
|
||||
github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo=
|
||||
github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE=
|
||||
github.com/gohugoio/gift v0.2.0 h1:vA31pP0rTVmBxBrhpY3WEt+4zM4g+1sDqYeemwsYeqc=
|
||||
github.com/gohugoio/gift v0.2.0/go.mod h1:1Mrm5CjF33KpD749Dwj+UAjWZ3LC6cBXGuTMa5XwoP4=
|
||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 h1:pxlAea9eRwuAnt/zKbGqlFO2ZszpIe24YpOVLf+N+4I=
|
||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6/go.mod h1:m5hu1im5Qc7LDycVLvee6MPobJiRLBYHklypFJR0/aE=
|
||||
github.com/gohugoio/go-radix v1.2.0 h1:D5GTk8jIoeXirBSc2P4E4NdHKDrenk9k9N0ctU5Yrhg=
|
||||
github.com/gohugoio/go-radix v1.2.0/go.mod h1:k6vDa0ebpbpgtzSj9lPGJcA4AZwJ9xUNObUy2vczPFM=
|
||||
github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4=
|
||||
github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8=
|
||||
github.com/gohugoio/httpcache v0.8.0 h1:hNdsmGSELztetYCsPVgjA960zSa4dfEqqF/SficorCU=
|
||||
github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
|
||||
github.com/gohugoio/hugo v0.165.0 h1:E9uRFnDw3E+Nv69OU8vsD1YLI3+tRcSHhs82y4fBXdc=
|
||||
github.com/gohugoio/hugo v0.165.0/go.mod h1:mOASrT3UGLvAXPdRuY6zUH9SoKBy7VoMQIzMFf4CMi8=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0/go.mod h1:ob9PCHy/ocsQhTz68uxhyInaYCbbVNpOOrJkIoSeD+8=
|
||||
github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
|
||||
github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ=
|
||||
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 h1:fVLolA3dG6s0brGetsiDAtcnpMSwVa2LqXJEw/9RJG4=
|
||||
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6/go.mod h1:1xo+NFW5S+bEf2DKXhaxuzvcDN5AR6o/KMwvqC5Mkpo=
|
||||
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A=
|
||||
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
|
||||
github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U=
|
||||
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
|
||||
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 h1:WL8YKrt3UbOBqSRU7GpP5BTtQTMWtVtj+mfPijgZeIg=
|
||||
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792/go.mod h1:te5hX0dW4C5r6YbXs+6ysNr8Q5UTmdIqGbb+mlFiYmA=
|
||||
github.com/golangci/golangci-lint/v2 v2.13.2 h1:bCyq3E4vo9qwzifjpzJqYndEt7Ncva80qkk8G2B4TXU=
|
||||
github.com/golangci/golangci-lint/v2 v2.13.2/go.mod h1:5xaMd1kAxV7GSBPEyngw8gnnjoRocqW9UVKdBR6627w=
|
||||
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
|
||||
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
|
||||
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
|
||||
github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
|
||||
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
|
||||
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
|
||||
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
|
||||
github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
|
||||
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 h1:0kPqfqytQizeQ9WBk487NcsGcRdS8YhHeUhXxkfEAhA=
|
||||
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241/go.mod h1:lczq5oJ8PxpMESQG6dO3iFYTQPhpqoMOYw//n2FEnHA=
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM=
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s=
|
||||
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM=
|
||||
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs=
|
||||
github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk=
|
||||
github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc=
|
||||
github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM=
|
||||
github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8=
|
||||
github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc=
|
||||
github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk=
|
||||
github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY=
|
||||
github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU=
|
||||
github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA=
|
||||
github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M=
|
||||
github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8=
|
||||
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
|
||||
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
|
||||
github.com/jgautheron/goconst v1.11.0 h1:KgN90z5qXt5f0Uzf3cWXev3hfMMFUyNeKdpkSBRvLDk=
|
||||
github.com/jgautheron/goconst v1.11.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc=
|
||||
github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8=
|
||||
github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
|
||||
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
|
||||
github.com/kisielk/errcheck v1.20.0 h1:9rwHBNKzd4wkDWcROy3DvFGNqEPlkxBg305rvk7HabI=
|
||||
github.com/kisielk/errcheck v1.20.0/go.mod h1:O+f80MKNwX8Oor2jwgpeQ9An7uJm+hRSgT+h22knRJU=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98=
|
||||
github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs=
|
||||
github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w=
|
||||
github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
|
||||
github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ=
|
||||
github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM=
|
||||
github.com/ldez/gomoddirectives v0.10.0 h1:Zb23rfMMkG21B6pZ9OfESzemByP5Qr7YvWcXQZE/S3Q=
|
||||
github.com/ldez/gomoddirectives v0.10.0/go.mod h1:W9uh0721Vc7aaVoiMXTpKIveLmtHC9uaOSiHM4OK5a8=
|
||||
github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
|
||||
github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
|
||||
github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk=
|
||||
github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY=
|
||||
github.com/ldez/tagliatelle v0.8.0 h1:QczqVPa22JdT40p3m5JTTVmPMv5wDORqdomJn6/Pnn0=
|
||||
github.com/ldez/tagliatelle v0.8.0/go.mod h1:K9n83Wv0wyVq3JYF9WI28BX8xXhDBVP3thHJ0ENuiEs=
|
||||
github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc=
|
||||
github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ=
|
||||
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
|
||||
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE=
|
||||
github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U=
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww=
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM=
|
||||
github.com/manuelarte/funcorder v0.6.0 h1:0hBngc4fa1IgNiI65A7sFGkMvoMCc878RjqB5V7rWP0=
|
||||
github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g=
|
||||
github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8=
|
||||
github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ=
|
||||
github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs=
|
||||
github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc=
|
||||
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
|
||||
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
|
||||
github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4=
|
||||
github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs=
|
||||
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mgechev/revive v1.16.0 h1:9OZVO8xOPUwIYynplHXv7liMllPQ/BTNZLp07sM/Im0=
|
||||
github.com/mgechev/revive v1.16.0/go.mod h1:nT854jMmvQsS/qqxEaMPM4pJSUpxhcPoHM788ZxDvdQ=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
|
||||
github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
|
||||
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
|
||||
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
|
||||
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
|
||||
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
|
||||
github.com/nishanths/exhaustive v0.13.0 h1:Rof0axOSNQaaPaPmLK5RxcRrJV2Vcv+vfUmuRy+2EAM=
|
||||
github.com/nishanths/exhaustive v0.13.0/go.mod h1:3/adQjJgvD/UFHHSYBjpK9Wbz8DOvf0FBtzx+zI0Rlo=
|
||||
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
|
||||
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
|
||||
github.com/nunnatsa/ginkgolinter v0.24.0 h1:Mp0EagluLFP98JatP6nqp/gGEoljNG97uf9AcxcBVy8=
|
||||
github.com/nunnatsa/ginkgolinter v0.24.0/go.mod h1:2ZMRuzX6+3XXyY6UZOwb6n+MCocVGbkIsDBC4vuWz5c=
|
||||
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
|
||||
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
|
||||
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
|
||||
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
|
||||
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
|
||||
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
|
||||
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
|
||||
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
|
||||
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
|
||||
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
|
||||
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
||||
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
|
||||
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
|
||||
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
|
||||
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
|
||||
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
|
||||
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
|
||||
github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
|
||||
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
|
||||
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
|
||||
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
|
||||
github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA=
|
||||
github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE=
|
||||
github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY=
|
||||
github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
|
||||
github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo=
|
||||
github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng=
|
||||
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU=
|
||||
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
|
||||
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs=
|
||||
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ=
|
||||
github.com/raeperd/recvcheck v0.3.1 h1:p7nZMxiCWkdUtY2i3o+pv1h4fJCR5gpvqAXr9326hEY=
|
||||
github.com/raeperd/recvcheck v0.3.1/go.mod h1:CbE7iRocQ4uc71JwpW/CPSh4BShnkre4A9Gb508RypU=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
|
||||
github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
|
||||
github.com/ryancurrah/gomodguard/v2 v2.1.3 h1:E7sz3PJwE9Ba1reVxSpF6XLCPJZ74Kfw/LabTNM4GIA=
|
||||
github.com/ryancurrah/gomodguard/v2 v2.1.3/go.mod h1:CQicdLGatWMxLX53JzoBjYlsNZhHbmLv2AVa0s2aivU=
|
||||
github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg=
|
||||
github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0=
|
||||
github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw=
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
|
||||
github.com/securego/gosec/v2 v2.29.0 h1:pF2HSLcnY5voqpxQumEe0O5YAAbvF4Syu4qeIewpLyc=
|
||||
github.com/securego/gosec/v2 v2.29.0/go.mod h1:47C7ej+L2bC1NOxteU2aCGnCi4cZaoKzRt5y9plIvuY=
|
||||
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
|
||||
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
|
||||
github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs=
|
||||
github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
|
||||
github.com/sourcegraph/go-diff v0.9.0 h1:7xcVsayWHhvxZGrqt1o89vvBApCa4nyCgO7tNxDHJ+Q=
|
||||
github.com/sourcegraph/go-diff v0.9.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0=
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tdewolff/minify/v2 v2.24.16 h1:1d+IHElvmYM2zJtuykMK/esiqc4H/7pqPKAa7jpSUUU=
|
||||
github.com/tdewolff/minify/v2 v2.24.16/go.mod h1:G6yep6HFo3u2eoOcIVBKSNwpoW2wXvPjvnFRj3w1zlc=
|
||||
github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8feI=
|
||||
github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
|
||||
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA=
|
||||
github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
|
||||
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag=
|
||||
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
|
||||
github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
|
||||
github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c h1:d/FUP8GD4QiY/rdFzZmBu3KqL9eBNVzMu7W/5pGkaMM=
|
||||
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
|
||||
github.com/timonwong/loggercheck v0.12.0 h1:7Cb9XGLJZSWYNjgL459DIhpSiEHupD+MLQ6vaN2Pl3g=
|
||||
github.com/timonwong/loggercheck v0.12.0/go.mod h1:Wa6Y+SJHQR01mViZlLLY5GAZVOWOLNMbkXddMjFWQCs=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
|
||||
github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI=
|
||||
github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA=
|
||||
github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g=
|
||||
github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
|
||||
github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
|
||||
github.com/uudashr/iface v1.5.1 h1:BS3yrgaT55s3dAtJoxuTnsZNbsejq/mVuzp49YDyfBA=
|
||||
github.com/uudashr/iface v1.5.1/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw=
|
||||
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
|
||||
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
|
||||
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
|
||||
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
|
||||
github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
|
||||
github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
|
||||
github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4=
|
||||
github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw=
|
||||
github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
|
||||
gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=
|
||||
go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
|
||||
go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=
|
||||
go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
|
||||
go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
|
||||
go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4=
|
||||
go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I=
|
||||
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
|
||||
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
|
||||
go.augendre.info/fatcontext v0.10.1 h1:HCS/+eob32cM0mEtmZM6NiXdqvS5KiZmsR+fCubTpbc=
|
||||
go.augendre.info/fatcontext v0.10.1/go.mod h1:pqpGvA9GlrXy+aXkp8L2dKz12Zp4g2FhzcAtwToU+2w=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba h1:ZoZmFkcuQ289xJilBm9Ck3W+/okrhXc4LUpbtKFQE4k=
|
||||
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba/go.mod h1:qkMnvgP8C7+xkci04txL2SJFc/0PGqnkf6gs5JUC43E=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||
golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
|
||||
golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
|
||||
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w=
|
||||
honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
|
||||
mvdan.cc/gofumpt v0.12.0 h1:1Lbudkz2kpM9Cjz2pL4M19u7q+GaEhCTNf7N9mfpcho=
|
||||
mvdan.cc/gofumpt v0.12.0/go.mod h1:SmBHHrljiZu/uoypeKup3rFzP6eoC9UwCp2iH5E3jZA=
|
||||
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 h1:Re1NRyLpiAt9kB+ImaaoapwWiQXrKwER4tY8fLOkDew=
|
||||
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8/go.mod h1:MrS/+zJ1M2xvGXhKktiHbNQeQPyg3Qel+KkzT+f7/i4=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
@@ -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")
|
||||
|
||||
@@ -40,14 +40,6 @@ func TestParseVersionCommand(t *testing.T) {
|
||||
assert.Equal(t, "version", ctx.Command())
|
||||
}
|
||||
|
||||
func TestParseInitCommand(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"init"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "init", ctx.Command())
|
||||
}
|
||||
|
||||
func TestParseGlobalConfigFlag(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+3
-10
@@ -25,8 +25,6 @@ type versionCmd struct{}
|
||||
|
||||
type setupCmd struct{}
|
||||
|
||||
type initCmd struct{}
|
||||
|
||||
type SetCmd struct {
|
||||
User string `short:"u" help:"Deprecated: comma-separated username,password. Use 'user set' instead."`
|
||||
Admin bool `short:"a" help:"Create user as admin (used with -u)"`
|
||||
@@ -118,7 +116,6 @@ type cliRoot struct {
|
||||
Run runCmd `cmd:"" default:"1" hidden:"" help:"Start the FileBrowser server"`
|
||||
Version versionCmd `cmd:"" name:"version" help:"Print version information"`
|
||||
Setup setupCmd `cmd:"" name:"setup" help:"Interactive configuration setup"`
|
||||
Init initCmd `cmd:"" name:"init" hidden:"" help:"Initialize the database or migrate from a legacy database"`
|
||||
Set SetCmd `cmd:"" name:"set" help:"Set configuration values (deprecated: use 'user set' for users)"`
|
||||
User UserCmd `cmd:"" name:"user" help:"User management"`
|
||||
}
|
||||
@@ -136,10 +133,6 @@ func (setupCmd) Run(globals *Globals) error {
|
||||
return createConfig(globals.Config, globals.NoInput)
|
||||
}
|
||||
|
||||
func (initCmd) Run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SetCmd) Run(ctx *kong.Context) error {
|
||||
if strings.HasPrefix(ctx.Command(), "set rule") {
|
||||
return nil
|
||||
@@ -217,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 == "init" || cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
|
||||
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:
|
||||
|
||||
+3
-30
@@ -1,9 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -172,12 +170,6 @@ func migrateFromBoltToSQLite() error {
|
||||
}
|
||||
}()
|
||||
|
||||
// Migrate auth signing key from legacy Bolt settings
|
||||
logger.Info("Migrating auth signing key...")
|
||||
if err := migrateAuthSigningKey(oldDB, sqlStore); err != nil {
|
||||
return fmt.Errorf("failed to migrate auth signing key: %w", err)
|
||||
}
|
||||
|
||||
// Migrate users (bolt User.ID is stored as SQLite users.user_id)
|
||||
logger.Info("Migrating users...")
|
||||
if err := migrateUsers(oldDB, sqlStore); err != nil {
|
||||
@@ -217,22 +209,6 @@ func migrateFromBoltToSQLite() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateAuthSigningKey(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
|
||||
key, err := state.AuthSigningKeyFromBoltDB(oldDB)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
logger.Info(" No legacy auth signing key in Bolt settings")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read legacy auth signing key: %w", err)
|
||||
}
|
||||
if err := sqlStore.SaveSetting("auth.signingKey", base64.StdEncoding.EncodeToString([]byte(key))); err != nil {
|
||||
return fmt.Errorf("save auth signing key: %w", err)
|
||||
}
|
||||
logger.Info(" ✓ Migrated auth signing key from legacy database")
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeUserScopesBeforeSQLite copies scopes from FrontendScopes into BackendScopes when the Bolt
|
||||
// record only had JSON key "scopes" (FrontendUser) and nothing under backendScopes — CreateUser
|
||||
// persists only BackendScopes in SQLite user_data.
|
||||
@@ -307,9 +283,6 @@ func migrateUsers(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
|
||||
if err := sqlStore.CreateUser(user); err != nil {
|
||||
return fmt.Errorf("failed to save user %s (bolt id: %d): %w", user.Username, boltID, err)
|
||||
}
|
||||
if err := state.BackfillUserTokenHashesOnStore(sqlStore, user); err != nil {
|
||||
return fmt.Errorf("failed to backfill token hashes for user %s: %w", user.Username, err)
|
||||
}
|
||||
}
|
||||
|
||||
if promoted > 0 {
|
||||
@@ -463,20 +436,20 @@ func migrateAccessRules(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
|
||||
|
||||
// Migrate revoked tokens
|
||||
for tokenHash := range storage.RevokedTokens {
|
||||
err := sqlStore.SaveRevokedToken(tokenHash, 0)
|
||||
err := sqlStore.SaveRevokedToken(tokenHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save revoked token: %w", err)
|
||||
}
|
||||
}
|
||||
logger.Infof(" ✓ Migrated %d revoked tokens", len(storage.RevokedTokens))
|
||||
|
||||
// Migrate hashed tokens (bolt stored owner user id); legacy rows are named API tokens.
|
||||
// Migrate hashed tokens (bolt stored owner user id)
|
||||
for tokenHash, userID := range storage.HashedTokens {
|
||||
if userID == 0 {
|
||||
logger.Warningf(" skipping hashed token: invalid user id 0")
|
||||
continue
|
||||
}
|
||||
err := sqlStore.SaveHashedToken(tokenHash, uint64(userID), false)
|
||||
err := sqlStore.SaveHashedToken(tokenHash, uint64(userID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save hashed token: %w", err)
|
||||
}
|
||||
|
||||
+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)
|
||||
|
||||
+76
-17
@@ -7,10 +7,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -27,8 +27,7 @@ func validateUserInfo(newDB bool) {
|
||||
changedFields := make([]string, 0, 8)
|
||||
changePass := false
|
||||
|
||||
scopesChanged := updateUserScopes(user)
|
||||
if scopesChanged {
|
||||
if updateUserScopes(user) {
|
||||
changedFields = append(changedFields, "backendScopes")
|
||||
}
|
||||
if updatePermissions(user) {
|
||||
@@ -46,7 +45,7 @@ func validateUserInfo(newDB bool) {
|
||||
if updateShowFirstLogin(user) {
|
||||
changedFields = append(changedFields, "showFirstLogin")
|
||||
}
|
||||
if updateSidebarLinks(user, scopesChanged) {
|
||||
if updateSidebarLinks(user) {
|
||||
changedFields = append(changedFields, "sidebarLinks")
|
||||
}
|
||||
if updateTokens(user) {
|
||||
@@ -82,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
|
||||
}
|
||||
|
||||
@@ -129,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
|
||||
}
|
||||
|
||||
@@ -233,18 +273,37 @@ func updatePreviewSettings(user *users.User) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// updateSidebarLinks normalizes saved links and adds defaults for new or changed scopes.
|
||||
func updateSidebarLinks(user *users.User, scopesChanged bool) bool {
|
||||
links, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks)
|
||||
if scopesChanged || user.SidebarLinks == nil {
|
||||
links, changed = usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
|
||||
}
|
||||
if !changed {
|
||||
return false
|
||||
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
|
||||
func updateSidebarLinks(user *users.User) bool {
|
||||
updated := false
|
||||
|
||||
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
|
||||
user.SidebarLinks = normalized
|
||||
updated = true
|
||||
}
|
||||
|
||||
user.SidebarLinks = links
|
||||
return true
|
||||
if !usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes) {
|
||||
return updated
|
||||
}
|
||||
|
||||
if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 && len(user.SidebarLinks) > 0 {
|
||||
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
|
||||
} else if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 {
|
||||
logger.Infof("User %s has no source sidebar links, building from scopes", user.Username)
|
||||
} else {
|
||||
logger.Infof("User %s is missing sidebar links for some scoped sources, merging from scopes", user.Username)
|
||||
}
|
||||
|
||||
merged, changed := usersidebar.EnsureSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
|
||||
if changed {
|
||||
user.SidebarLinks = merged
|
||||
updated = true
|
||||
}
|
||||
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
|
||||
user.SidebarLinks = normalized
|
||||
updated = true
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
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, true)
|
||||
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) {
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestUpdateSidebarLinks_dedupesDuplicateMigratedLinks(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user, false) {
|
||||
if !updateSidebarLinks(user) {
|
||||
t.Fatal("expected updateSidebarLinks to return true")
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user, false) {
|
||||
if !updateSidebarLinks(user) {
|
||||
t.Fatal("expected updateSidebarLinks to return true")
|
||||
}
|
||||
|
||||
@@ -77,37 +77,3 @@ func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
|
||||
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSidebarLinks_keepsDeletedSourceLinkAbsent(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
SidebarLinks: []users.SidebarLink{
|
||||
{Name: "playwright + files", Category: "source", SourceName: fixturePlaywrightSource, Target: "/"},
|
||||
{Name: "docker", Category: "source", SourceName: fixtureDockerSource, Target: "/"},
|
||||
},
|
||||
},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: fixturePlaywrightSource, Scope: "/"},
|
||||
{Path: fixtureDockerSource, Scope: "/"},
|
||||
{Path: fixtureAccessSource, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user, false) {
|
||||
t.Fatal("expected default source names to be blanked on normalize")
|
||||
}
|
||||
if len(user.SidebarLinks) != 2 {
|
||||
t.Fatalf("got %d sidebar links, want 2: %v", len(user.SidebarLinks), user.SidebarLinks)
|
||||
}
|
||||
for _, link := range user.SidebarLinks {
|
||||
if link.Name != "" {
|
||||
t.Fatalf("expected blank default name, got %#v", link)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,23 +0,0 @@
|
||||
http:
|
||||
port: 8080
|
||||
baseURL: "/"
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|warning|error"
|
||||
apiFilter: "disabled"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
defaultPermissions:
|
||||
view: true
|
||||
download: true
|
||||
modify: true
|
||||
create: true
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
minLength: 5
|
||||
signup: false
|
||||
+258
-49
@@ -1,107 +1,316 @@
|
||||
module github.com/gtsteffaniak/filebrowser/backend
|
||||
|
||||
go 1.27.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/alecthomas/kong v1.16.1
|
||||
github.com/alecthomas/kong v1.16.0
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/evanoberholster/imagemeta v1.0.0
|
||||
github.com/gen2brain/go-fitz v1.28.2
|
||||
github.com/go-ldap/ldap/v3 v3.4.14
|
||||
github.com/go-playground/validator/v10 v10.30.4
|
||||
github.com/go-webauthn/webauthn v0.18.1
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.0.0
|
||||
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.52
|
||||
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.57.0
|
||||
golang.org/x/image v0.46.0
|
||||
golang.org/x/mod v0.41.0
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/oauth2 v0.37.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.org/x/term v0.46.0
|
||||
golang.org/x/time v0.16.0
|
||||
golang.org/x/tools v0.50.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/mod v0.38.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.48.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.59.0
|
||||
modernc.org/sqlite v1.55.0
|
||||
)
|
||||
|
||||
require (
|
||||
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.3 // indirect
|
||||
codeberg.org/chavacava/garif v0.2.0 // indirect
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
|
||||
dev.gaijin.team/go/golib v0.6.0 // indirect
|
||||
github.com/4meepo/tagalign v1.4.3 // indirect
|
||||
github.com/Abirdcfly/dupword v0.1.7 // indirect
|
||||
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
|
||||
github.com/AlwxSin/noinlineerr v1.0.5 // indirect
|
||||
github.com/Antonboom/errname v1.1.1 // indirect
|
||||
github.com/Antonboom/nilnil v1.1.1 // indirect
|
||||
github.com/Antonboom/testifylint v1.6.4 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect
|
||||
github.com/Djarvur/go-err113 v0.1.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.5.0 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.67.4 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
|
||||
github.com/alexkohler/prealloc v1.1.0 // indirect
|
||||
github.com/alfatraining/structtag v1.0.0 // indirect
|
||||
github.com/alingse/asasalint v0.0.11 // indirect
|
||||
github.com/alingse/nilnesserr v0.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
|
||||
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||
github.com/bep/golibsass v1.2.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.5 // indirect
|
||||
github.com/bkielbasa/cyclop v1.2.3 // indirect
|
||||
github.com/blizzy78/varnamelen v0.8.0 // indirect
|
||||
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
|
||||
github.com/bombsimon/wsl/v5 v5.8.0 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/breml/bidichk v0.3.3 // indirect
|
||||
github.com/breml/errchkjson v0.4.1 // indirect
|
||||
github.com/butuzov/ireturn v0.4.1 // indirect
|
||||
github.com/butuzov/mirror v1.3.0 // indirect
|
||||
github.com/catenacyber/perfsprint v0.10.1 // indirect
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charithe/durationcheck v0.0.11 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/ckaznocha/intrange v0.3.1 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/curioswitch/go-reassign v0.3.0 // indirect
|
||||
github.com/daixiang0/gci v0.13.7 // indirect
|
||||
github.com/dave/dst v0.27.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/denis-tingaikin/go-header v0.5.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/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.1.0 // indirect
|
||||
github.com/ebitengine/purego v0.11.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/ettle/strcase v0.2.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/firefart/nonamedreturns v1.0.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.20 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-critic/go-critic v0.14.3 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.1 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.2 // indirect
|
||||
github.com/go-openapi/spec v1.0.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/loading v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/pools v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.0 // indirect
|
||||
github.com/go-openapi/spec v0.22.9 // indirect
|
||||
github.com/go-openapi/swag/conv v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/pools v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-toolsmith/astcast v1.1.0 // indirect
|
||||
github.com/go-toolsmith/astcopy v1.1.0 // indirect
|
||||
github.com/go-toolsmith/astequal v1.2.0 // indirect
|
||||
github.com/go-toolsmith/astfmt v1.1.0 // indirect
|
||||
github.com/go-toolsmith/astp v1.1.0 // indirect
|
||||
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.1 // 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 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/snappy v0.0.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-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-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
|
||||
github.com/gordonklaus/ineffassign v0.2.0 // indirect
|
||||
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
|
||||
github.com/gostaticanalysis/comment v1.5.0 // indirect
|
||||
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
|
||||
github.com/gostaticanalysis/nilerr v0.1.2 // indirect
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hexops/gotextdiff v1.0.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jgautheron/goconst v1.10.0 // indirect
|
||||
github.com/jjti/go-spancheck v0.6.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
|
||||
github.com/kisielk/errcheck v1.10.0 // indirect
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/kulti/thelper v0.7.1 // indirect
|
||||
github.com/kunwardeep/paralleltest v1.0.15 // indirect
|
||||
github.com/lasiar/canonicalheader v1.1.2 // indirect
|
||||
github.com/ldez/exptostd v0.4.5 // indirect
|
||||
github.com/ldez/gomoddirectives v0.8.0 // indirect
|
||||
github.com/ldez/grignotin v0.10.1 // indirect
|
||||
github.com/ldez/structtags v0.6.1 // indirect
|
||||
github.com/ldez/tagliatelle v0.7.2 // indirect
|
||||
github.com/ldez/usetesting v0.5.0 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/leonklingele/grouper v1.1.2 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/macabu/inamedparam v0.2.0 // indirect
|
||||
github.com/magiconair/properties v1.8.6 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
|
||||
github.com/manuelarte/funcorder v0.6.0 // indirect
|
||||
github.com/maratori/testableexamples v1.0.1 // indirect
|
||||
github.com/maratori/testpackage v1.1.2 // indirect
|
||||
github.com/matoous/godox v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/mgechev/revive v1.15.0 // indirect
|
||||
github.com/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/nakabonne/nestif v0.3.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/nishanths/exhaustive v0.12.0 // indirect
|
||||
github.com/nishanths/predeclared v0.2.2 // indirect
|
||||
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_golang v1.12.1 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.32.1 // indirect
|
||||
github.com/prometheus/procfs v0.7.3 // indirect
|
||||
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
|
||||
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
|
||||
github.com/quasilyte/gogrep v0.5.0 // indirect
|
||||
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
|
||||
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
|
||||
github.com/raeperd/recvcheck v0.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // 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/sanposhiho/wastedassign/v2 v2.1.0 // 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.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.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.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.12 // indirect
|
||||
github.com/tetafro/godot v1.5.6 // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
|
||||
github.com/timonwong/loggercheck v0.11.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
|
||||
github.com/ultraware/funlen v0.2.0 // indirect
|
||||
github.com/ultraware/whitespace v0.2.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.3.0 // indirect
|
||||
github.com/uudashr/gocognit v1.2.1 // indirect
|
||||
github.com/uudashr/iface v1.4.2 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xen0n/gosmopolitan v1.3.0 // indirect
|
||||
github.com/xo/terminfo 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
|
||||
gitlab.com/bosi/decorder v0.4.2 // indirect
|
||||
go-simpler.org/musttag v0.14.0 // indirect
|
||||
go-simpler.org/sloglint v0.12.0 // indirect
|
||||
go.augendre.info/arangolint v0.4.0 // indirect
|
||||
go.augendre.info/fatcontext v0.9.0 // indirect
|
||||
go.etcd.io/bbolt v1.5.0 // 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/sync v0.23.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/libc v1.76.0 // 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
|
||||
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
|
||||
)
|
||||
|
||||
exclude (
|
||||
github.com/alecthomas/go-check-sumtype v0.5.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.2.0
|
||||
tool (
|
||||
github.com/air-verse/air
|
||||
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
|
||||
github.com/swaggo/swag/cmd/swag
|
||||
)
|
||||
+1152
-103
File diff suppressed because it is too large.
Load diff
@@ -15,13 +15,13 @@ import (
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -131,10 +131,10 @@ func checkPermissionsImpl(opts utils.FileOptions, user *users.User, s *Service)
|
||||
return "", "", fmt.Errorf("user not provided")
|
||||
}
|
||||
if opts.Path == "" {
|
||||
return "", "", fmt.Errorf("%w: path not provided", errors.ErrInvalidRequestParams)
|
||||
return "", "", fmt.Errorf("path not provided")
|
||||
}
|
||||
if opts.Source == "" {
|
||||
return "", "", fmt.Errorf("%w: source not provided", errors.ErrInvalidRequestParams)
|
||||
return "", "", fmt.Errorf("source not provided")
|
||||
}
|
||||
|
||||
// Get index
|
||||
@@ -154,7 +154,7 @@ func checkPermissionsImpl(opts utils.FileOptions, user *users.User, s *Service)
|
||||
return "", "", errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
indexPath := utils.JoinScopedIndexPath(userScope, safePath)
|
||||
indexPath := utils.JoinPathAsUnix(userScope, safePath)
|
||||
parsedPath, err := utils.ParseSanitizedIndexPath(indexPath, true)
|
||||
if err != nil {
|
||||
return "", "", errors.ErrAccessDenied
|
||||
@@ -190,7 +190,7 @@ func GetDirItems(opts utils.FileOptions, user *users.User) (Items, error) {
|
||||
|
||||
func getDirItemsImpl(opts utils.FileOptions, user *users.User, s *Service) (Items, error) {
|
||||
items := Items{}
|
||||
indexPath, userScope, topLevelErr := checkPermissionsImpl(opts, user, s)
|
||||
indexPath, _, topLevelErr := checkPermissionsImpl(opts, user, s)
|
||||
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
|
||||
if topLevelErr != nil && !accessRulesErr {
|
||||
return items, topLevelErr
|
||||
@@ -200,18 +200,14 @@ func getDirItemsImpl(opts utils.FileOptions, user *users.User, s *Service) (Item
|
||||
if idx == nil {
|
||||
return items, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
infoReq := indexing.FileInfoRequest{
|
||||
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
HideFileExt: opts.HideFileExt,
|
||||
Expand: true,
|
||||
SkipExtendedAttrs: true,
|
||||
}
|
||||
if opts.FollowSymlinks {
|
||||
infoReq.BoundIndexPath = userScope
|
||||
}
|
||||
info, err := idx.GetFileInfo(infoReq)
|
||||
})
|
||||
if err != nil {
|
||||
return items, err // Path excluded by index rules OR doesn't exist
|
||||
}
|
||||
@@ -265,18 +261,14 @@ func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (*
|
||||
}
|
||||
// Layer 2: INDEX RULES (global)
|
||||
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
|
||||
fileInfoReq := indexing.FileInfoRequest{
|
||||
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
HideFileExt: opts.HideFileExt,
|
||||
Expand: opts.Expand,
|
||||
SkipExtendedAttrs: opts.SkipExtendedAttrs,
|
||||
}
|
||||
if opts.FollowSymlinks {
|
||||
fileInfoReq.BoundIndexPath = userScope
|
||||
}
|
||||
info, err := idx.GetFileInfo(fileInfoReq)
|
||||
})
|
||||
if err != nil {
|
||||
return response, err // Path excluded by index rules OR doesn't exist
|
||||
}
|
||||
@@ -293,15 +285,7 @@ func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (*
|
||||
|
||||
// Build response
|
||||
response.FileInfo = *info
|
||||
if opts.FollowSymlinks && info.Type != "directory" {
|
||||
realPath, _, err := idx.GetRealPathScoped(userScope, indexPath)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.RealPath = realPath
|
||||
} else {
|
||||
response.RealPath = utils.JoinUnderSourceRoot(idx.Path, indexPath)
|
||||
}
|
||||
response.RealPath = filepath.Join(idx.Path, indexPath)
|
||||
response.Source = opts.Source
|
||||
if user.Permissions.Share && opts.ShowSharedAttr {
|
||||
for i := range response.Files {
|
||||
@@ -403,10 +387,6 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
return
|
||||
}
|
||||
|
||||
if !opts.Content {
|
||||
return
|
||||
}
|
||||
|
||||
// Process text content for non-video, non-audio files
|
||||
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
|
||||
content, err := getContent(info.RealPath)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -128,7 +126,8 @@ func copySingleFile(source, dest string) error {
|
||||
defer dst.Close()
|
||||
|
||||
// Copy the contents of the file.
|
||||
if err = copyFileContents(dst, src); err != nil {
|
||||
_, err = io.Copy(dst, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -147,18 +146,6 @@ func copySingleFile(source, dest string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFileContents(dst io.Writer, src io.Reader) error {
|
||||
_, err := io.Copy(dst, src)
|
||||
if !errors.Is(err, syscall.EBADF) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some FUSE filesystems reject copy_file_range. Hide the optional fast-path
|
||||
// methods so io.Copy retries from the current offsets with buffered I/O.
|
||||
_, err = io.Copy(struct{ io.Writer }{dst}, struct{ io.Reader }{src})
|
||||
return err
|
||||
}
|
||||
|
||||
// copyDirectory handles copying directories recursively.
|
||||
func copyDirectory(source, dest string) error {
|
||||
srcInfo, err := os.Stat(source)
|
||||
|
||||
@@ -1,33 +1,12 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type copyFileRangeRejectingWriter struct {
|
||||
bytes.Buffer
|
||||
readFromCalls int
|
||||
}
|
||||
|
||||
func (w *copyFileRangeRejectingWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||
w.readFromCalls++
|
||||
buf := make([]byte, 4)
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
_, _ = w.Write(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
return int64(n), err
|
||||
}
|
||||
return int64(n), syscall.EBADF
|
||||
}
|
||||
|
||||
func TestUnixModeToFileMode_setgid(t *testing.T) {
|
||||
m := unixModeToFileMode(0o2770)
|
||||
if got := m.Perm(); got != 0o770 {
|
||||
@@ -138,19 +117,3 @@ func TestCopyFilePreservesModTime(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileContentsFallsBackAfterCopyFileRangeBadFileDescriptor(t *testing.T) {
|
||||
const content = "complete file contents"
|
||||
src := struct{ io.Reader }{bytes.NewBufferString(content)}
|
||||
dst := ©FileRangeRejectingWriter{}
|
||||
|
||||
if err := copyFileContents(dst, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := dst.String(); got != content {
|
||||
t.Fatalf("copied contents = %q, want %q", got, content)
|
||||
}
|
||||
if dst.readFromCalls != 1 {
|
||||
t.Fatalf("ReadFrom calls = %d, want 1", dst.readFromCalls)
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// GetAggregatedPartitionUsage returns total and used bytes summed across distinct
|
||||
// filesystems mounted at or under root (deduped by major:minor from mountinfo).
|
||||
// Falls back to a single-path probe when mountinfo cannot be read.
|
||||
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
|
||||
if root == "" {
|
||||
return 0, 0, fmt.Errorf("empty path")
|
||||
}
|
||||
root = filepath.Clean(root)
|
||||
|
||||
paths, parseErr := distinctMountPathsUnder(root)
|
||||
if parseErr != nil || len(paths) == 0 {
|
||||
return singlePathPartitionUsage(root)
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for _, path := range paths {
|
||||
t, u, stErr := partitionUsageAt(path)
|
||||
if stErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = stErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
total += t
|
||||
used += u
|
||||
}
|
||||
|
||||
if total == 0 && used == 0 {
|
||||
if firstErr != nil {
|
||||
return 0, 0, firstErr
|
||||
}
|
||||
return singlePathPartitionUsage(root)
|
||||
}
|
||||
return total, used, nil
|
||||
}
|
||||
|
||||
func singlePathPartitionUsage(root string) (total, used uint64, err error) {
|
||||
total, err = GetPartitionSize(root)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
used, err = GetPartitionUsed(root)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return total, used, nil
|
||||
}
|
||||
|
||||
func distinctMountPathsUnder(root string) ([]string, error) {
|
||||
data, err := readMountinfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths, err := distinctMountPathsFromMountinfo(data, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If mountinfo had no covering mount for root, ensure root is probed.
|
||||
if len(paths) == 0 {
|
||||
return []string{root}, nil
|
||||
}
|
||||
hasRoot := false
|
||||
for _, p := range paths {
|
||||
if p == root {
|
||||
hasRoot = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRoot {
|
||||
if dev, stErr := deviceIDFromPath(root); stErr == nil {
|
||||
// Avoid duplicating a device already selected under another path.
|
||||
already := false
|
||||
for _, p := range paths {
|
||||
if d, e := deviceIDFromPath(p); e == nil && d == dev {
|
||||
already = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !already {
|
||||
paths = append(paths, root)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func readMountinfo() (string, error) {
|
||||
for _, path := range []string{"/proc/self/mountinfo", "/proc/1/mountinfo"} {
|
||||
b, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
return string(b), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("mountinfo not available")
|
||||
}
|
||||
|
||||
func deviceIDFromPath(path string) (string, error) {
|
||||
var st unix.Stat_t
|
||||
if err := unix.Stat(path, &st); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%d:%d", unix.Major(uint64(st.Dev)), unix.Minor(uint64(st.Dev))), nil
|
||||
}
|
||||
|
||||
func partitionUsageAt(path string) (total, used uint64, err error) {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
bsize := blockSize(&stat)
|
||||
total = uint64(stat.Blocks) * bsize
|
||||
free := uint64(stat.Bavail) * bsize
|
||||
if total >= free {
|
||||
used = total - free
|
||||
}
|
||||
return total, used, nil
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type mountEntry struct {
|
||||
deviceID string
|
||||
mountpoint string
|
||||
}
|
||||
|
||||
// distinctMountPathsFromMountinfo selects one path per distinct filesystem that
|
||||
// contributes capacity for root: the FS that owns root, plus nested mounts under
|
||||
// root. Deduped by major:minor from mountinfo.
|
||||
func distinctMountPathsFromMountinfo(mountinfo string, root string) ([]string, error) {
|
||||
root = path.Clean(root)
|
||||
entries, err := parseAllMounts(mountinfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]string)
|
||||
|
||||
var covering *mountEntry
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
mp := e.mountpoint
|
||||
if mp == root || strings.HasPrefix(root, mp+"/") || mp == "/" {
|
||||
if covering == nil || len(mp) > len(covering.mountpoint) {
|
||||
covering = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if covering != nil {
|
||||
seen[covering.deviceID] = root
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
mp := e.mountpoint
|
||||
if mp != root && !strings.HasPrefix(mp, root+"/") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[e.deviceID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[e.deviceID] = mp
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(seen))
|
||||
uniq := make(map[string]struct{}, len(seen))
|
||||
for _, p := range seen {
|
||||
if _, ok := uniq[p]; ok {
|
||||
continue
|
||||
}
|
||||
uniq[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseAllMounts(mountinfo string) ([]mountEntry, error) {
|
||||
var out []mountEntry
|
||||
scanner := bufio.NewScanner(strings.NewReader(mountinfo))
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
for scanner.Scan() {
|
||||
entry, ok := parseMountinfoLine(scanner.Text())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entry.mountpoint = path.Clean(entry.mountpoint)
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, scanner.Err()
|
||||
}
|
||||
|
||||
func parseMountinfoLine(line string) (mountEntry, bool) {
|
||||
parts := strings.SplitN(line, " - ", 2)
|
||||
if len(parts) != 2 {
|
||||
return mountEntry{}, false
|
||||
}
|
||||
fields := strings.Fields(parts[0])
|
||||
if len(fields) < 5 {
|
||||
return mountEntry{}, false
|
||||
}
|
||||
return mountEntry{
|
||||
deviceID: fields[2],
|
||||
mountpoint: unescapeFstab(fields[4]),
|
||||
}, true
|
||||
}
|
||||
|
||||
func unescapeFstab(path string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(path))
|
||||
for i := 0; i < len(path); i++ {
|
||||
if path[i] == '\\' && i+3 < len(path) {
|
||||
if n, err := strconv.ParseUint(path[i+1:i+4], 8, 8); err == nil {
|
||||
b.WriteByte(byte(n))
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(path[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDistinctMountPathsNestedDisk(t *testing.T) {
|
||||
// /srv lives on rootfs 8:1; /srv/data is a separate 2TB disk 8:16.
|
||||
mountinfo := "" +
|
||||
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
|
||||
"45 22 8:16 /data /srv/data rw - btrfs /dev/sdb1 rw\n" +
|
||||
"50 22 0:47 / /proc rw - proc proc rw\n"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
want := []string{"/srv", "/srv/data"}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("paths = %v, want %v", paths, want)
|
||||
}
|
||||
for i := range want {
|
||||
if paths[i] != want[i] {
|
||||
t.Fatalf("paths = %v, want %v", paths, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistinctMountPathsOvermountSamePathDeduped(t *testing.T) {
|
||||
mountinfo := "" +
|
||||
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
|
||||
"40 22 8:2 / /srv rw - ext4 /dev/sda2 rw\n" +
|
||||
"45 40 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n" +
|
||||
"46 45 0:47 / /srv/data rw - tmpfs tmpfs rw\n"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
want := []string{"/srv", "/srv/data"}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("paths = %v, want %v (no duplicate probe paths)", paths, want)
|
||||
}
|
||||
for i := range want {
|
||||
if paths[i] != want[i] {
|
||||
t.Fatalf("paths = %v, want %v", paths, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistinctMountPathsBindSameDeviceNotDuplicated(t *testing.T) {
|
||||
mountinfo := "" +
|
||||
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
|
||||
"45 22 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n" +
|
||||
"46 22 8:16 / /srv/data/bind rw,bind - btrfs /dev/sdb1 rw\n"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(paths) != 2 {
|
||||
t.Fatalf("expected 2 distinct devices, got %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistinctMountPathsSourceIsOwnMount(t *testing.T) {
|
||||
mountinfo := "" +
|
||||
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
|
||||
"40 22 8:2 / /srv rw - ext4 /dev/sda2 rw\n" +
|
||||
"45 40 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
want := []string{"/srv", "/srv/data"}
|
||||
if len(paths) != 2 || paths[0] != want[0] || paths[1] != want[1] {
|
||||
t.Fatalf("paths = %v, want %v", paths, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistinctMountPathsIgnoresUnrelated(t *testing.T) {
|
||||
mountinfo := "" +
|
||||
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
|
||||
"30 22 8:3 / /home rw - ext4 /dev/sda3 rw\n"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(paths) != 1 || paths[0] != "/srv" {
|
||||
t.Fatalf("paths = %v, want [/srv]", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMountinfoEscapedSpace(t *testing.T) {
|
||||
entry, ok := parseMountinfoLine(`36 35 98:0 /mnt1 /mnt\040two rw - ext3 /dev/root rw`)
|
||||
if !ok {
|
||||
t.Fatal("expected parse ok")
|
||||
}
|
||||
if entry.mountpoint != "/mnt two" {
|
||||
t.Fatalf("mountpoint = %q, want /mnt two", entry.mountpoint)
|
||||
}
|
||||
if entry.deviceID != "98:0" {
|
||||
t.Fatalf("deviceID = %q, want 98:0", entry.deviceID)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package fileutils
|
||||
|
||||
// GetAggregatedPartitionUsage returns total and used bytes for the filesystem
|
||||
// that owns root. Non-Linux builds use a single statfs/GetDiskFreeSpaceEx path.
|
||||
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
|
||||
total, err = GetPartitionSize(root)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
used, err = GetPartitionUsed(root)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return total, used, nil
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func blockSize(stat *syscall.Statfs_t) uint64 {
|
||||
if stat.Frsize > 0 {
|
||||
return uint64(stat.Frsize)
|
||||
}
|
||||
return uint64(stat.Bsize)
|
||||
}
|
||||
|
||||
// GetPartitionSize returns the filesystem size for Linux systems
|
||||
func GetPartitionSize(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(stat.Blocks) * blockSize(&stat), nil
|
||||
}
|
||||
|
||||
// GetFreeSpace returns the available free space for Linux systems
|
||||
func GetFreeSpace(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(stat.Bavail) * blockSize(&stat), nil
|
||||
}
|
||||
|
||||
// GetPartitionUsed returns the used space for Linux systems (total - free)
|
||||
func GetPartitionUsed(path string) (uint64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
bsize := blockSize(&stat)
|
||||
total := uint64(stat.Blocks) * bsize
|
||||
free := uint64(stat.Bavail) * bsize
|
||||
return total - free, nil
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBlockSizePrefersFrsize(t *testing.T) {
|
||||
// virtiofs (Docker Desktop for Mac) reports Bsize as the 1 MiB optimal
|
||||
// transfer size while Blocks are still counted in the 4 KiB fragment unit.
|
||||
stat := &syscall.Statfs_t{
|
||||
Bsize: 1048576,
|
||||
Frsize: 4096,
|
||||
Blocks: 1465121792,
|
||||
Bavail: 1054717543,
|
||||
}
|
||||
|
||||
got := blockSize(stat)
|
||||
if got != 4096 {
|
||||
t.Fatalf("blockSize = %d, want 4096 (Frsize)", got)
|
||||
}
|
||||
|
||||
total := uint64(stat.Blocks) * blockSize(stat)
|
||||
const wantTotal = 1465121792 * 4096 // ~5.5 TiB, matches df
|
||||
if total != wantTotal {
|
||||
t.Fatalf("total = %d bytes, want %d (~5.5 TiB)", total, uint64(wantTotal))
|
||||
}
|
||||
// Sanity: the old Bsize-based math overstates by exactly 256x.
|
||||
if inflated := uint64(stat.Blocks) * uint64(stat.Bsize); inflated != total*256 {
|
||||
t.Fatalf("expected 256x inflation from Bsize, got %d vs %d", inflated, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockSizeFallsBackToBsize(t *testing.T) {
|
||||
// Normal filesystems report Frsize == Bsize (or leave Frsize unset on
|
||||
// exotic drivers); either way the accounting unit is recovered.
|
||||
stat := &syscall.Statfs_t{Bsize: 4096, Frsize: 4096}
|
||||
if got := blockSize(stat); got != 4096 {
|
||||
t.Fatalf("blockSize = %d, want 4096", got)
|
||||
}
|
||||
|
||||
zero := &syscall.Statfs_t{Bsize: 4096, Frsize: 0}
|
||||
if got := blockSize(zero); got != 4096 {
|
||||
t.Fatalf("blockSize with Frsize=0 = %d, want 4096 (Bsize fallback)", got)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows && !linux
|
||||
//go:build !windows
|
||||
|
||||
package fileutils
|
||||
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
hostPlatformUnknown = "unknown"
|
||||
hostPlatformGenericLinux = "generic-linux"
|
||||
hostPlatformUnraid = "unraid"
|
||||
hostPlatformTrueNAS = "truenas"
|
||||
hostPlatformSynology = "synology"
|
||||
hostPlatformProxmox = "proxmox"
|
||||
hostPlatformWindows = "windows"
|
||||
hostPlatformMacOS = "macos"
|
||||
)
|
||||
|
||||
// Roots where a Docker image may mount the host filesystem.
|
||||
var hostRootPrefixes = []string{"/host", "/rootfs", "/mnt/host", ""}
|
||||
|
||||
// nativeHostRootPrefix is the scan root for non-container host detection. Tests may override.
|
||||
var nativeHostRootPrefix = ""
|
||||
|
||||
func detectHostPlatform(containerRuntime string) string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return hostPlatformWindows
|
||||
case "darwin":
|
||||
return hostPlatformMacOS
|
||||
case "linux":
|
||||
return detectLinuxHostPlatform(containerRuntime)
|
||||
case "freebsd":
|
||||
return detectFreeBSDHostPlatform(containerRuntime)
|
||||
default:
|
||||
return hostPlatformUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func hostScanPrefixes(containerRuntime string) ([]string, bool) {
|
||||
inContainer := containerRuntime == "docker" || containerRuntime == "kubernetes"
|
||||
if !inContainer {
|
||||
return []string{nativeHostRootPrefix}, false
|
||||
}
|
||||
return hostRootPrefixes, true
|
||||
}
|
||||
|
||||
func detectLinuxHostPlatform(containerRuntime string) string {
|
||||
prefixes, inContainer := hostScanPrefixes(containerRuntime)
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
if platform := detectLinuxMarkers(prefix); platform != "" {
|
||||
return platform
|
||||
}
|
||||
}
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
if platform := classifyOSRelease(readOSRelease(prefix)); platform != "" {
|
||||
if platform != hostPlatformGenericLinux || !inContainer || prefix != "" {
|
||||
return platform
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if inContainer {
|
||||
return hostPlatformUnknown
|
||||
}
|
||||
|
||||
if classifyOSRelease(readOSRelease(nativeHostRootPrefix)) == hostPlatformGenericLinux {
|
||||
return hostPlatformGenericLinux
|
||||
}
|
||||
|
||||
return hostPlatformUnknown
|
||||
}
|
||||
|
||||
func detectFreeBSDHostPlatform(containerRuntime string) string {
|
||||
prefixes, inContainer := hostScanPrefixes(containerRuntime)
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
if platform := classifyOSRelease(readOSRelease(prefix)); platform != "" {
|
||||
if platform != hostPlatformGenericLinux {
|
||||
return platform
|
||||
}
|
||||
}
|
||||
if platform := classifyFreeBSDVersion(readFile(hostPath(prefix, "etc/version"))); platform != "" {
|
||||
return platform
|
||||
}
|
||||
}
|
||||
|
||||
if inContainer {
|
||||
return hostPlatformUnknown
|
||||
}
|
||||
|
||||
return hostPlatformUnknown
|
||||
}
|
||||
|
||||
func classifyFreeBSDVersion(content string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(content))
|
||||
switch {
|
||||
case strings.Contains(lower, "truenas"), strings.Contains(lower, "freenas"):
|
||||
return hostPlatformTrueNAS
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func detectLinuxMarkers(rootPrefix string) string {
|
||||
markers := []struct {
|
||||
path string
|
||||
platform string
|
||||
}{
|
||||
{"etc/unraid-version", hostPlatformUnraid},
|
||||
{"etc/synoinfo.conf", hostPlatformSynology},
|
||||
{"etc/pve/.version", hostPlatformProxmox},
|
||||
}
|
||||
|
||||
for _, marker := range markers {
|
||||
if fileExists(hostPath(rootPrefix, marker.path)) {
|
||||
return marker.platform
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readOSRelease(rootPrefix string) string {
|
||||
return readFile(hostPath(rootPrefix, "etc/os-release"))
|
||||
}
|
||||
|
||||
func hostPath(rootPrefix, rel string) string {
|
||||
if rootPrefix == "" {
|
||||
return filepath.Join(string(os.PathSeparator), rel)
|
||||
}
|
||||
return filepath.Join(rootPrefix, rel)
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func readFile(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func parseOSRelease(content string) map[string]string {
|
||||
values := make(map[string]string)
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"`)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func classifyOSRelease(content string) string {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
vars := parseOSRelease(content)
|
||||
id := strings.ToLower(vars["ID"])
|
||||
idLike := strings.ToLower(vars["ID_LIKE"])
|
||||
pretty := strings.ToLower(vars["PRETTY_NAME"])
|
||||
name := strings.ToLower(vars["NAME"])
|
||||
combined := strings.Join([]string{id, idLike, pretty, name}, " ")
|
||||
|
||||
switch {
|
||||
case strings.Contains(combined, "unraid"):
|
||||
return hostPlatformUnraid
|
||||
case strings.Contains(combined, "truenas"), strings.Contains(combined, "freenas"):
|
||||
return hostPlatformTrueNAS
|
||||
case strings.Contains(combined, "synology"):
|
||||
return hostPlatformSynology
|
||||
case strings.Contains(combined, "proxmox"):
|
||||
return hostPlatformProxmox
|
||||
case id != "":
|
||||
return hostPlatformGenericLinux
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
deploymentRuntimeKubernetes = "kubernetes"
|
||||
deploymentRuntimeDocker = "docker"
|
||||
deploymentRuntimeLinux = "linux"
|
||||
deploymentRuntimeWindows = "windows"
|
||||
deploymentRuntimeMacOS = "macos"
|
||||
deploymentRuntimeUnknown = "unknown"
|
||||
)
|
||||
|
||||
// detectDeploymentRuntime reports how the instance is deployed: detected NAS/hypervisor
|
||||
// product when possible, otherwise kubernetes, docker, or native OS (linux, windows, macos).
|
||||
func detectDeploymentRuntime() string {
|
||||
return deploymentRuntimeFrom(detectRuntime())
|
||||
}
|
||||
|
||||
func deploymentRuntimeFrom(containerRuntime string) string {
|
||||
platform := detectHostPlatform(containerRuntime)
|
||||
if isProductDeploymentRuntime(platform) {
|
||||
return platform
|
||||
}
|
||||
|
||||
switch containerRuntime {
|
||||
case "kubernetes":
|
||||
return deploymentRuntimeKubernetes
|
||||
case "docker":
|
||||
return deploymentRuntimeDocker
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return deploymentRuntimeWindows
|
||||
case "darwin":
|
||||
return deploymentRuntimeMacOS
|
||||
case "linux":
|
||||
return deploymentRuntimeLinux
|
||||
default:
|
||||
return deploymentRuntimeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func isProductDeploymentRuntime(platform string) bool {
|
||||
switch platform {
|
||||
case hostPlatformUnraid, hostPlatformTrueNAS, hostPlatformSynology, hostPlatformProxmox,
|
||||
hostPlatformWindows, hostPlatformMacOS:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyOSRelease(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unraid",
|
||||
content: `NAME="Unraid OS"
|
||||
ID=unraid
|
||||
ID_LIKE="debian"`,
|
||||
want: hostPlatformUnraid,
|
||||
},
|
||||
{
|
||||
name: "truenas scale",
|
||||
content: `NAME="TrueNAS-SCALE"
|
||||
ID=truenas-scale
|
||||
ID_LIKE=debian`,
|
||||
want: hostPlatformTrueNAS,
|
||||
},
|
||||
{
|
||||
name: "freenas",
|
||||
content: `PRETTY_NAME="FreeNAS-11.3-U5"`,
|
||||
want: hostPlatformTrueNAS,
|
||||
},
|
||||
{
|
||||
name: "synology",
|
||||
content: `NAME="Synology"
|
||||
ID=synology`,
|
||||
want: hostPlatformSynology,
|
||||
},
|
||||
{
|
||||
name: "proxmox",
|
||||
content: `PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
|
||||
ID=debian
|
||||
NAME="Proxmox VE"`,
|
||||
want: hostPlatformProxmox,
|
||||
},
|
||||
{
|
||||
name: "generic ubuntu",
|
||||
content: `NAME="Ubuntu"
|
||||
ID=ubuntu
|
||||
ID_LIKE=debian`,
|
||||
want: hostPlatformGenericLinux,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
content: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := classifyOSRelease(tt.content); got != tt.want {
|
||||
t.Fatalf("classifyOSRelease() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func withHostDetectionRoots(t *testing.T, roots []string, nativeRoot string) {
|
||||
t.Cleanup(func() {
|
||||
hostRootPrefixes = []string{"/host", "/rootfs", "/mnt/host", ""}
|
||||
nativeHostRootPrefix = ""
|
||||
})
|
||||
hostRootPrefixes = roots
|
||||
nativeHostRootPrefix = nativeRoot
|
||||
}
|
||||
|
||||
func writeFixtureFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%q): %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%q): %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectLinuxHostPlatformMarkerPrecedence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
hostMount := filepath.Join(root, "host")
|
||||
writeFixtureFile(t, filepath.Join(hostMount, "etc", "unraid-version"), "6.12.0")
|
||||
writeFixtureFile(t, filepath.Join(hostMount, "etc", "os-release"), `NAME="Ubuntu"
|
||||
ID=ubuntu`)
|
||||
|
||||
withHostDetectionRoots(t, []string{hostMount}, "")
|
||||
|
||||
if got := detectLinuxHostPlatform("docker"); got != hostPlatformUnraid {
|
||||
t.Fatalf("detectLinuxHostPlatform() = %q, want %q", got, hostPlatformUnraid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectLinuxHostPlatformMountedOSRelease(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
hostMount := filepath.Join(root, "host")
|
||||
writeFixtureFile(t, filepath.Join(hostMount, "etc", "os-release"), `NAME="Synology"
|
||||
ID=synology`)
|
||||
|
||||
withHostDetectionRoots(t, []string{hostMount}, "")
|
||||
|
||||
if got := detectLinuxHostPlatform("kubernetes"); got != hostPlatformSynology {
|
||||
t.Fatalf("detectLinuxHostPlatform() = %q, want %q", got, hostPlatformSynology)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectLinuxHostPlatformContainerUnknownFallback(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
withHostDetectionRoots(t, []string{filepath.Join(root, "host")}, "")
|
||||
|
||||
if got := detectLinuxHostPlatform("docker"); got != hostPlatformUnknown {
|
||||
t.Fatalf("detectLinuxHostPlatform() = %q, want %q", got, hostPlatformUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectLinuxHostPlatformNativeSkipsHostMounts(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
hostOnly := filepath.Join(root, "host")
|
||||
nativeRoot := filepath.Join(root, "native")
|
||||
writeFixtureFile(t, filepath.Join(hostOnly, "etc", "unraid-version"), "6.12.0")
|
||||
writeFixtureFile(t, filepath.Join(nativeRoot, "etc", "os-release"), `NAME="Ubuntu"
|
||||
ID=ubuntu`)
|
||||
|
||||
withHostDetectionRoots(t, []string{hostOnly}, nativeRoot)
|
||||
|
||||
if got := detectLinuxHostPlatform("native"); got != hostPlatformGenericLinux {
|
||||
t.Fatalf("detectLinuxHostPlatform() = %q, want %q", got, hostPlatformGenericLinux)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFreeBSDHostPlatformTrueNASVersion(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
hostMount := filepath.Join(root, "host")
|
||||
writeFixtureFile(t, filepath.Join(hostMount, "etc", "version"), "TrueNAS-13.0-U6.1")
|
||||
|
||||
withHostDetectionRoots(t, []string{hostMount}, "")
|
||||
|
||||
if got := detectFreeBSDHostPlatform("docker"); got != hostPlatformTrueNAS {
|
||||
t.Fatalf("detectFreeBSDHostPlatform() = %q, want %q", got, hostPlatformTrueNAS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyFreeBSDVersion(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"TrueNAS-13.0-U6.1": hostPlatformTrueNAS,
|
||||
"FreeNAS-11.3-U5": hostPlatformTrueNAS,
|
||||
"13.2-RELEASE": "",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := classifyFreeBSDVersion(input); got != want {
|
||||
t.Fatalf("classifyFreeBSDVersion(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploymentRuntimeFrom(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("deployment runtime integration requires GOOS linux")
|
||||
}
|
||||
|
||||
t.Run("platform beats docker", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
hostMount := filepath.Join(root, "host")
|
||||
writeFixtureFile(t, filepath.Join(hostMount, "etc", "unraid-version"), "6.12.0")
|
||||
withHostDetectionRoots(t, []string{hostMount}, "")
|
||||
if got := deploymentRuntimeFrom("docker"); got != hostPlatformUnraid {
|
||||
t.Fatalf("deploymentRuntimeFrom() = %q, want %q", got, hostPlatformUnraid)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("docker without host visibility", func(t *testing.T) {
|
||||
emptyHost := filepath.Join(t.TempDir(), "host")
|
||||
withHostDetectionRoots(t, []string{emptyHost}, "")
|
||||
if got := deploymentRuntimeFrom("docker"); got != deploymentRuntimeDocker {
|
||||
t.Fatalf("deploymentRuntimeFrom() = %q, want %q", got, deploymentRuntimeDocker)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("kubernetes", func(t *testing.T) {
|
||||
withHostDetectionRoots(t, []string{filepath.Join(t.TempDir(), "host")}, "")
|
||||
if got := deploymentRuntimeFrom("kubernetes"); got != deploymentRuntimeKubernetes {
|
||||
t.Fatalf("deploymentRuntimeFrom() = %q, want %q", got, deploymentRuntimeKubernetes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("native linux", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
nativeRoot := filepath.Join(root, "native")
|
||||
writeFixtureFile(t, filepath.Join(nativeRoot, "etc", "os-release"), `NAME="Ubuntu"
|
||||
ID=ubuntu`)
|
||||
withHostDetectionRoots(t, []string{filepath.Join(root, "unused-host")}, nativeRoot)
|
||||
if got := deploymentRuntimeFrom("native"); got != deploymentRuntimeLinux {
|
||||
t.Fatalf("deploymentRuntimeFrom() = %q, want %q", got, deploymentRuntimeLinux)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -52,9 +52,7 @@ type systemPayload struct {
|
||||
CPUCores int `json:"cpu_cores"`
|
||||
MemoryRSSMB int `json:"memory_rss_mb"`
|
||||
MemoryTotalMB int `json:"memory_total_mb"`
|
||||
// Runtime is deployment context: unraid, synology, truenas, proxmox, kubernetes,
|
||||
// docker, linux, windows, macos, or unknown.
|
||||
Runtime string `json:"runtime"`
|
||||
Runtime string `json:"runtime"`
|
||||
}
|
||||
|
||||
type countsPayload struct {
|
||||
@@ -180,7 +178,7 @@ func collectPayload(versionLabel string) (snapshotPayload, error) {
|
||||
CPUCores: runtime.NumCPU(),
|
||||
MemoryRSSMB: memoryRSSMB(),
|
||||
MemoryTotalMB: systemTotalMemoryMB(),
|
||||
Runtime: detectDeploymentRuntime(),
|
||||
Runtime: detectRuntime(),
|
||||
},
|
||||
Counts: countsPayload{
|
||||
SourcesEnabled: sourceCounts.enabled,
|
||||
@@ -310,7 +308,7 @@ func mapSystemOS(goos string) string {
|
||||
|
||||
func mapSystemArch(goarch string) string {
|
||||
switch goarch {
|
||||
case "amd64", "arm64", "386", "arm", "riscv64":
|
||||
case "amd64", "arm64", "386", "arm":
|
||||
return goarch
|
||||
default:
|
||||
return "other"
|
||||
|
||||
@@ -53,9 +53,14 @@ func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
|
||||
}
|
||||
if !minimal {
|
||||
claim.Permissions = users.SanitizeTokenPermissions(perms)
|
||||
claim.BelongsTo = user.ID
|
||||
}
|
||||
signedClaims := jwt.Claims(claim)
|
||||
if minimal {
|
||||
// Sign only standard JWT claims. The full AuthToken struct always JSON-marshals
|
||||
// a zero Permissions object, which bloats the token past WebDAV client limits.
|
||||
signedClaims = claim.MinimalAuthToken
|
||||
}
|
||||
// Always sign minimal JWT claims only; identity is resolved via hashed_tokens lookup.
|
||||
signedClaims := jwt.Claims(claim.MinimalAuthToken)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, signedClaims)
|
||||
tokenString, err := token.SignedString([]byte(settings.Config.Auth.Key))
|
||||
if err != nil {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
// JWTSigningKeyBytes returns the HMAC key or an error if it is unset (empty key must never verify).
|
||||
func JWTSigningKeyBytes() ([]byte, error) {
|
||||
key := settings.Config.Auth.Key
|
||||
if len(key) == 0 {
|
||||
return nil, fmt.Errorf("auth signing key is not configured")
|
||||
}
|
||||
return []byte(key), nil
|
||||
}
|
||||
|
||||
// JWTSigningKeyFunc is the keyFunc for FileBrowser-issued session and API JWTs.
|
||||
func JWTSigningKeyFunc() jwt.Keyfunc {
|
||||
return func(token *jwt.Token) (interface{}, error) {
|
||||
return JWTSigningKeyBytes()
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestJWTSigningKeyFuncRejectsEmptyKey(t *testing.T) {
|
||||
orig := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = ""
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = orig })
|
||||
|
||||
_, err := JWTSigningKeyFunc()(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty signing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTSigningKeyFuncAcceptsConfiguredKey(t *testing.T) {
|
||||
orig := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = orig })
|
||||
|
||||
key, err := JWTSigningKeyFunc()(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
keyBytes, ok := key.([]byte)
|
||||
if !ok || string(keyBytes) != "test-key" {
|
||||
t.Fatalf("got key %T %q", key, key)
|
||||
}
|
||||
}
|
||||
@@ -1,122 +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 := JWTSigningKeyFunc()
|
||||
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
|
||||
|
||||
@@ -27,24 +27,31 @@ func TestMinimalSignedTokenLengthUnder256(t *testing.T) {
|
||||
assertMinimalJWTClaims(t, tokenString)
|
||||
}
|
||||
|
||||
func TestCustomizedTokenMetadataRetainsPermissions(t *testing.T) {
|
||||
func TestCustomizedTokenEmbedsPermissionsAndIsLonger(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-signing-key-for-length-check"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
user := &users.User{ID: 42, FrontendUser: users.FrontendUser{Username: "webdavuser"}}
|
||||
_, claim, err := MakeSignedTokenAPI(user, "custom", time.Hour*24*365, users.Permissions{Api: true}, false)
|
||||
minimal, _, err := MakeSignedTokenAPI(user, "min", time.Hour*24*365, users.Permissions{}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !claim.Permissions.Api {
|
||||
t.Fatalf("expected api permission in returned metadata: %+v", claim.Permissions)
|
||||
}
|
||||
tokenString, _, err := MakeSignedTokenAPI(user, "custom2", time.Hour*24*365, users.Permissions{Api: true}, false)
|
||||
custom, _, err := MakeSignedTokenAPI(user, "custom", time.Hour*24*365, users.Permissions{Api: true}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertMinimalJWTClaims(t, tokenString)
|
||||
if len(custom) <= len(minimal) {
|
||||
t.Fatalf("custom token should be longer than minimal: custom=%d minimal=%d", len(custom), len(minimal))
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
if _, _, err := jwt.NewParser().ParseUnverified(custom, claims); err != nil {
|
||||
t.Fatalf("parse custom token: %v", err)
|
||||
}
|
||||
if _, ok := claims["Permissions"]; !ok {
|
||||
t.Fatal("custom token payload should include Permissions claim")
|
||||
}
|
||||
}
|
||||
|
||||
func assertMinimalJWTClaims(t *testing.T, tokenString string) {
|
||||
|
||||
@@ -61,25 +61,15 @@ type FrontendAccessRule struct {
|
||||
// GroupMap maps group names to a set of usernames.
|
||||
type GroupMap map[string]StringSet
|
||||
|
||||
// HashedTokenInfo describes a registered bearer token owner and type.
|
||||
type HashedTokenInfo struct {
|
||||
UserID uint64
|
||||
IsSession bool
|
||||
}
|
||||
|
||||
// sessionTokenRetireGrace is how long a rotated session token stays usable after
|
||||
// renewal. In-flight requests still carrying the previous cookie must not 401.
|
||||
const sessionTokenRetireGrace = 2 * time.Minute
|
||||
|
||||
// Storage manages access rules and group membership.
|
||||
type Storage struct {
|
||||
mux sync.RWMutex
|
||||
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
|
||||
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
|
||||
RevokedTokens map[string]int64 // token hash → revocation unix time (0 = immediate) - in-memory authoritative state
|
||||
HashedTokens map[string]HashedTokenInfo // maps token hash → owner/type - in-memory authoritative state
|
||||
Users *users.Storage // Reference to users storage
|
||||
sqlStore SQLPersister // SQL store for persistence
|
||||
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
|
||||
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
|
||||
RevokedTokens map[string]struct{} // set of revoked token hashes - in-memory authoritative state
|
||||
HashedTokens map[string]uint64 // maps token hash → owner user id - in-memory authoritative state
|
||||
Users *users.Storage // Reference to users storage
|
||||
sqlStore SQLPersister // SQL store for persistence
|
||||
}
|
||||
|
||||
// SQLPersister interface for SQL persistence operations
|
||||
@@ -88,11 +78,8 @@ type SQLPersister interface {
|
||||
DeleteAccessRule(source, path string) error
|
||||
SaveGroup(name string, members StringSet) error
|
||||
DeleteGroup(name string) error
|
||||
SaveRevokedToken(tokenHash string, revokedAt int64) error
|
||||
PersistImmediateTokenRevocation(tokenHash string) error
|
||||
PersistTokenRetirement(tokenHash string, revokedAt int64, pruned []string) error
|
||||
DeleteRevokedToken(tokenHash string) error
|
||||
SaveHashedToken(tokenHash string, userID uint64, isSession bool) error
|
||||
SaveRevokedToken(tokenHash string) error
|
||||
SaveHashedToken(tokenHash string, userID uint64) error
|
||||
DeleteHashedToken(tokenHash string) error
|
||||
DeleteHashedTokensByUserID(userID uint64) error
|
||||
}
|
||||
@@ -102,46 +89,18 @@ 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{
|
||||
AllRules: make(SourceRuleMap),
|
||||
Groups: make(GroupMap),
|
||||
RevokedTokens: make(map[string]int64),
|
||||
HashedTokens: make(map[string]HashedTokenInfo),
|
||||
RevokedTokens: make(map[string]struct{}),
|
||||
HashedTokens: make(map[string]uint64),
|
||||
Users: usersStore,
|
||||
}
|
||||
return s
|
||||
@@ -276,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
|
||||
@@ -292,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
|
||||
@@ -553,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
|
||||
}
|
||||
|
||||
@@ -585,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,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
|
||||
}
|
||||
@@ -635,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
|
||||
}
|
||||
|
||||
@@ -1313,210 +1268,61 @@ func (s *Storage) UpdateRulePath(sourcePath string, oldPath, newPath utils.Index
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeToken immediately invalidates a token: the owner mapping is removed and
|
||||
// the hash is persisted as revoked with timestamp 0 (no grace window).
|
||||
func (s *Storage) RevokeToken(tokenString string) error {
|
||||
tokenHash := utils.HashSHA256(tokenString)
|
||||
var rollback tokenRevokeRollback
|
||||
// RevokeToken adds a token hash to the revoked list and persists to DB.
|
||||
func (s *Storage) RevokeToken(tokenHash string) error {
|
||||
tokenHash = utils.HashSHA256(tokenHash)
|
||||
s.mux.Lock()
|
||||
rollback.capture(s, tokenHash)
|
||||
s.RevokedTokens[tokenHash] = 0
|
||||
s.RevokedTokens[tokenHash] = struct{}{}
|
||||
delete(s.HashedTokens, tokenHash)
|
||||
sqlStore := s.sqlStore
|
||||
s.mux.Unlock()
|
||||
|
||||
if sqlStore == nil {
|
||||
return nil
|
||||
}
|
||||
if err := sqlStore.PersistImmediateTokenRevocation(tokenHash); err != nil {
|
||||
s.mux.Lock()
|
||||
rollback.apply(s)
|
||||
s.mux.Unlock()
|
||||
return err
|
||||
if sqlStore != nil {
|
||||
_ = sqlStore.DeleteHashedToken(tokenHash)
|
||||
_ = sqlStore.SaveRevokedToken(tokenHash)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetireToken schedules a rotated session token for revocation after
|
||||
// sessionTokenRetireGrace. The owner mapping is kept during the grace window so
|
||||
// in-flight requests still carrying the previous cookie stay authenticated.
|
||||
func (s *Storage) RetireToken(tokenString string) error {
|
||||
tokenHash := utils.HashSHA256(tokenString)
|
||||
now := time.Now()
|
||||
var rollback tokenRetireRollback
|
||||
s.mux.Lock()
|
||||
rollback.capture(s, tokenHash, now)
|
||||
s.RevokedTokens[tokenHash] = now.Unix()
|
||||
pruned := s.pruneRevocationsNL(now)
|
||||
sqlStore := s.sqlStore
|
||||
s.mux.Unlock()
|
||||
|
||||
if sqlStore == nil {
|
||||
return nil
|
||||
}
|
||||
if err := sqlStore.PersistTokenRetirement(tokenHash, now.Unix(), pruned); err != nil {
|
||||
s.mux.Lock()
|
||||
rollback.apply(s)
|
||||
s.mux.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type tokenRevokeRollback struct {
|
||||
tokenHash string
|
||||
hadHashed bool
|
||||
hashed HashedTokenInfo
|
||||
hadRevoked bool
|
||||
revokedAt int64
|
||||
}
|
||||
|
||||
func (rb *tokenRevokeRollback) capture(s *Storage, tokenHash string) {
|
||||
rb.tokenHash = tokenHash
|
||||
if info, ok := s.HashedTokens[tokenHash]; ok {
|
||||
rb.hadHashed = true
|
||||
rb.hashed = info
|
||||
}
|
||||
if at, ok := s.RevokedTokens[tokenHash]; ok {
|
||||
rb.hadRevoked = true
|
||||
rb.revokedAt = at
|
||||
}
|
||||
}
|
||||
|
||||
func (rb *tokenRevokeRollback) apply(s *Storage) {
|
||||
if rb.hadHashed {
|
||||
s.HashedTokens[rb.tokenHash] = rb.hashed
|
||||
} else {
|
||||
delete(s.HashedTokens, rb.tokenHash)
|
||||
}
|
||||
if rb.hadRevoked {
|
||||
s.RevokedTokens[rb.tokenHash] = rb.revokedAt
|
||||
} else {
|
||||
delete(s.RevokedTokens, rb.tokenHash)
|
||||
}
|
||||
}
|
||||
|
||||
type tokenRetireRollback struct {
|
||||
tokenHash string
|
||||
hadTokenRevoked bool
|
||||
tokenRevokedAt int64
|
||||
prunedRevoked map[string]int64
|
||||
prunedHashed map[string]HashedTokenInfo
|
||||
}
|
||||
|
||||
func (rb *tokenRetireRollback) capture(s *Storage, tokenHash string, now time.Time) {
|
||||
rb.tokenHash = tokenHash
|
||||
if at, ok := s.RevokedTokens[tokenHash]; ok {
|
||||
rb.hadTokenRevoked = true
|
||||
rb.tokenRevokedAt = at
|
||||
}
|
||||
revokedAfter := maps.Clone(s.RevokedTokens)
|
||||
revokedAfter[tokenHash] = now.Unix()
|
||||
rb.prunedRevoked = make(map[string]int64)
|
||||
rb.prunedHashed = make(map[string]HashedTokenInfo)
|
||||
for hash, revokedAt := range revokedAfter {
|
||||
if revokedAt != 0 && now.Sub(time.Unix(revokedAt, 0)) < sessionTokenRetireGrace {
|
||||
continue
|
||||
}
|
||||
if at, ok := s.RevokedTokens[hash]; ok {
|
||||
rb.prunedRevoked[hash] = at
|
||||
}
|
||||
if info, ok := s.HashedTokens[hash]; ok {
|
||||
rb.prunedHashed[hash] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rb *tokenRetireRollback) apply(s *Storage) {
|
||||
if rb.hadTokenRevoked {
|
||||
s.RevokedTokens[rb.tokenHash] = rb.tokenRevokedAt
|
||||
} else {
|
||||
delete(s.RevokedTokens, rb.tokenHash)
|
||||
}
|
||||
for hash, at := range rb.prunedRevoked {
|
||||
s.RevokedTokens[hash] = at
|
||||
}
|
||||
for hash, info := range rb.prunedHashed {
|
||||
s.HashedTokens[hash] = info
|
||||
}
|
||||
}
|
||||
|
||||
// pruneRevocationsNL drops revocation records that no longer need to be retained
|
||||
// and returns their hashes so the caller can clean up SQL. Immediate revocations
|
||||
// (timestamp 0) and retirements past the grace window no longer resolve a user
|
||||
// because their owner mapping is removed here. Caller must hold s.mux.
|
||||
func (s *Storage) pruneRevocationsNL(now time.Time) []string {
|
||||
var pruned []string
|
||||
for hash, revokedAt := range s.RevokedTokens {
|
||||
if revokedAt == 0 || now.Sub(time.Unix(revokedAt, 0)) >= sessionTokenRetireGrace {
|
||||
delete(s.RevokedTokens, hash)
|
||||
delete(s.HashedTokens, hash)
|
||||
pruned = append(pruned, hash)
|
||||
}
|
||||
}
|
||||
return pruned
|
||||
}
|
||||
|
||||
// IsTokenRevoked reports whether a token is revoked. Retired session tokens are
|
||||
// only considered revoked once the grace window has elapsed.
|
||||
func (s *Storage) IsTokenRevoked(tokenString string) bool {
|
||||
tokenHash := utils.HashSHA256(tokenString)
|
||||
// IsTokenRevoked checks if a token hash is in the revoked list (memory-only read).
|
||||
func (s *Storage) IsTokenRevoked(tokenHash string) bool {
|
||||
s.mux.RLock()
|
||||
revokedAt, exists := s.RevokedTokens[tokenHash]
|
||||
s.mux.RUnlock()
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
if revokedAt == 0 {
|
||||
return true
|
||||
}
|
||||
return time.Since(time.Unix(revokedAt, 0)) >= sessionTokenRetireGrace
|
||||
defer s.mux.RUnlock()
|
||||
tokenHash = utils.HashSHA256(tokenHash)
|
||||
_, exists := s.RevokedTokens[tokenHash]
|
||||
return exists
|
||||
}
|
||||
|
||||
// AddApiToken maps a named API token string hash to an owner user id.
|
||||
// AddApiToken maps a token string hash to an owner user id (for minimal JWTs).
|
||||
func (s *Storage) AddApiToken(tokenString string, userID uint64) error {
|
||||
return s.addHashedToken(tokenString, userID, false)
|
||||
}
|
||||
|
||||
// AddSessionToken maps a session bearer token string hash to an owner user id.
|
||||
func (s *Storage) AddSessionToken(tokenString string, userID uint64) error {
|
||||
return s.addHashedToken(tokenString, userID, true)
|
||||
}
|
||||
|
||||
func (s *Storage) addHashedToken(tokenString string, userID uint64, isSession bool) error {
|
||||
tokenHash := utils.HashSHA256(tokenString)
|
||||
s.mux.Lock()
|
||||
s.HashedTokens[tokenHash] = HashedTokenInfo{UserID: userID, IsSession: isSession}
|
||||
s.HashedTokens[tokenHash] = userID
|
||||
sqlStore := s.sqlStore
|
||||
s.mux.Unlock()
|
||||
if sqlStore != nil {
|
||||
return sqlStore.SaveHashedToken(tokenHash, userID, isSession)
|
||||
return sqlStore.SaveHashedToken(tokenHash, userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserIDFromToken retrieves the owner user id for a given token string (memory read; SQL populated at startup).
|
||||
func (s *Storage) GetUserIDFromToken(tokenString string) (uint64, bool) {
|
||||
info, ok := s.GetHashedTokenInfo(tokenString)
|
||||
return info.UserID, ok
|
||||
}
|
||||
|
||||
// GetHashedTokenInfo retrieves owner/type metadata for a given token string.
|
||||
func (s *Storage) GetHashedTokenInfo(tokenString string) (HashedTokenInfo, bool) {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
tokenHash := utils.HashSHA256(tokenString)
|
||||
info, exists := s.HashedTokens[tokenHash]
|
||||
return info, exists
|
||||
uid, exists := s.HashedTokens[tokenHash]
|
||||
return uid, exists
|
||||
}
|
||||
|
||||
// GetRevokedTokens returns a copy of the revocation map (hash → revocation unix time).
|
||||
func (s *Storage) GetRevokedTokens() map[string]int64 {
|
||||
// GetRevokedTokens returns a copy of all revoked token hashes.
|
||||
func (s *Storage) GetRevokedTokens() map[string]struct{} {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
result := make(map[string]int64, len(s.RevokedTokens))
|
||||
for k, v := range s.RevokedTokens {
|
||||
result[k] = v
|
||||
// Return a copy to prevent external modification
|
||||
result := make(map[string]struct{}, len(s.RevokedTokens))
|
||||
for k := range s.RevokedTokens {
|
||||
result[k] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1534,15 +1340,15 @@ func (s *Storage) RemoveApiToken(tokenString string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveHashedTokensForUser removes every bearer-token hash → user id mapping for that owner.
|
||||
// RemoveHashedTokensForUser removes every minimal-JWT hash → user id mapping for that owner.
|
||||
// Call when deleting a user so recycled usernames never resolve leftover token hashes to a new id.
|
||||
func (s *Storage) RemoveHashedTokensForUser(userID uint64) error {
|
||||
if userID == 0 {
|
||||
return nil
|
||||
}
|
||||
s.mux.Lock()
|
||||
for hash, info := range s.HashedTokens {
|
||||
if info.UserID == userID {
|
||||
for hash, uid := range s.HashedTokens {
|
||||
if uid == userID {
|
||||
delete(s.HashedTokens, hash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package access_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
)
|
||||
|
||||
type failingRevokePersister struct{}
|
||||
|
||||
func (failingRevokePersister) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
|
||||
func (failingRevokePersister) DeleteAccessRule(string, string) error { return nil }
|
||||
func (failingRevokePersister) SaveGroup(string, access.StringSet) error { return nil }
|
||||
func (failingRevokePersister) DeleteGroup(string) error { return nil }
|
||||
func (failingRevokePersister) SaveRevokedToken(string, int64) error { return nil }
|
||||
func (failingRevokePersister) PersistImmediateTokenRevocation(string) error {
|
||||
return errors.New("simulated revocation persistence failure")
|
||||
}
|
||||
func (failingRevokePersister) PersistTokenRetirement(string, int64, []string) error {
|
||||
return errors.New("simulated retirement persistence failure")
|
||||
}
|
||||
func (failingRevokePersister) DeleteRevokedToken(string) error { return nil }
|
||||
func (failingRevokePersister) SaveHashedToken(string, uint64, bool) error { return nil }
|
||||
func (failingRevokePersister) DeleteHashedToken(string) error { return nil }
|
||||
func (failingRevokePersister) DeleteHashedTokensByUserID(uint64) error { return nil }
|
||||
|
||||
func TestSessionAndApiTokenMetadata(t *testing.T) {
|
||||
store, _ := createTestStorage(t)
|
||||
|
||||
if err := store.AddSessionToken("session-token", 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessionInfo, ok := store.GetHashedTokenInfo("session-token")
|
||||
if !ok {
|
||||
t.Fatal("expected session token mapping")
|
||||
}
|
||||
if !sessionInfo.IsSession || sessionInfo.UserID != 42 {
|
||||
t.Fatalf("session token info = %+v, want user 42 session=true", sessionInfo)
|
||||
}
|
||||
|
||||
if err := store.AddApiToken("api-token", 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apiInfo, ok := store.GetHashedTokenInfo("api-token")
|
||||
if !ok {
|
||||
t.Fatal("expected api token mapping")
|
||||
}
|
||||
if apiInfo.IsSession {
|
||||
t.Fatalf("api token info = %+v, want session=false", apiInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeTokenRollsBackMemoryOnPersistenceFailure(t *testing.T) {
|
||||
store, _ := createTestStorage(t)
|
||||
store.SetSQLStore(failingRevokePersister{})
|
||||
|
||||
if err := store.AddSessionToken("tok", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RevokeToken("tok"); err == nil {
|
||||
t.Fatal("expected RevokeToken to propagate persistence failure")
|
||||
}
|
||||
if store.IsTokenRevoked("tok") {
|
||||
t.Fatal("failed revocation must not leave in-memory revoked state")
|
||||
}
|
||||
if _, ok := store.GetHashedTokenInfo("tok"); !ok {
|
||||
t.Fatal("failed revocation must restore owner mapping")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetireTokenRollsBackMemoryOnPersistenceFailure(t *testing.T) {
|
||||
store, _ := createTestStorage(t)
|
||||
store.SetSQLStore(failingRevokePersister{})
|
||||
|
||||
if err := store.AddSessionToken("tok", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RetireToken("tok"); err == nil {
|
||||
t.Fatal("expected RetireToken to propagate persistence failure")
|
||||
}
|
||||
if store.IsTokenRevoked("tok") {
|
||||
t.Fatal("failed retirement must not leave in-memory revoked state")
|
||||
}
|
||||
if _, ok := store.GetHashedTokenInfo("tok"); !ok {
|
||||
t.Fatal("failed retirement must keep owner mapping")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeTokenIsImmediate(t *testing.T) {
|
||||
store, _ := createTestStorage(t)
|
||||
|
||||
if err := store.AddSessionToken("tok", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.IsTokenRevoked("tok") {
|
||||
t.Fatal("token must not be revoked before RevokeToken")
|
||||
}
|
||||
if err := store.RevokeToken("tok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !store.IsTokenRevoked("tok") {
|
||||
t.Fatal("RevokeToken must take effect immediately")
|
||||
}
|
||||
if _, ok := store.GetHashedTokenInfo("tok"); ok {
|
||||
t.Fatal("immediate revocation must remove the owner mapping")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetireTokenHonorsGraceWindow(t *testing.T) {
|
||||
store, _ := createTestStorage(t)
|
||||
|
||||
if err := store.AddSessionToken("tok", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RetireToken("tok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.IsTokenRevoked("tok") {
|
||||
t.Fatal("retired token must remain valid during the grace window")
|
||||
}
|
||||
if _, ok := store.GetHashedTokenInfo("tok"); !ok {
|
||||
t.Fatal("retired token mapping must remain during the grace window")
|
||||
}
|
||||
|
||||
// Simulate the grace window elapsing.
|
||||
store.RevokedTokens[utils.HashSHA256("tok")] = time.Now().Add(-3 * time.Minute).Unix()
|
||||
if !store.IsTokenRevoked("tok") {
|
||||
t.Fatal("retired token must be revoked once the grace window elapses")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package share
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
@@ -61,34 +62,31 @@ func prepForFrontendOne(link *Share, viewer *users.User, r *http.Request, public
|
||||
}
|
||||
out.SingleFileShare = snap.IsSingleFileShare()
|
||||
if r != nil && publicHost != "" {
|
||||
out.DownloadURL = PublicShareURL(publicHost, publicScheme, out.Hash, true, out.HasPassword)
|
||||
out.ShareURL = PublicShareURL(publicHost, publicScheme, out.Hash, false, out.HasPassword)
|
||||
out.DownloadURL = PublicShareURL(publicHost, publicScheme, out.Hash, true, snap.Token)
|
||||
out.ShareURL = PublicShareURL(publicHost, publicScheme, out.Hash, false, snap.Token)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// PublicShareURL builds share/download URLs using pre-resolved host and scheme (see web/proxy.go).
|
||||
func PublicShareURL(host, scheme, hash string, isDirectDownload bool, hasPassword bool) string {
|
||||
if isDirectDownload && hasPassword {
|
||||
if settings.Config.Http.ExternalUrl != "" {
|
||||
return fmt.Sprintf("%s%spublic/share/%s?download=true",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%spublic/share/%s?download=true", scheme, host, settings.Config.Http.BaseURL, hash)
|
||||
func PublicShareURL(host, scheme, hash string, isDirectDownload bool, token string) string {
|
||||
tokenParam := ""
|
||||
if token != "" && isDirectDownload {
|
||||
tokenParam = fmt.Sprintf("&token=%s", url.QueryEscape(token))
|
||||
}
|
||||
|
||||
if settings.Config.Http.ExternalUrl != "" {
|
||||
if isDirectDownload {
|
||||
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
|
||||
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s%s",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash, tokenParam)
|
||||
}
|
||||
return fmt.Sprintf("%s%spublic/share/%s",
|
||||
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
|
||||
if isDirectDownload {
|
||||
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s",
|
||||
scheme, host, settings.Config.Http.BaseURL, hash)
|
||||
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s%s",
|
||||
scheme, host, settings.Config.Http.BaseURL, hash, tokenParam)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%spublic/share/%s", scheme, host, settings.Config.Http.BaseURL, hash)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestPublicShareURLPasswordProtectedDirectLink(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.BaseURL = "/files/"
|
||||
|
||||
got := PublicShareURL("example.com", "https", "abc123", true, true)
|
||||
want := "https://example.com/files/public/share/abc123?download=true"
|
||||
if got != want {
|
||||
t.Fatalf("PublicShareURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicShareURLDirectDownloadWithoutPassword(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.BaseURL = "/files/"
|
||||
|
||||
got := PublicShareURL("example.com", "https", "abc123", true, false)
|
||||
want := "https://example.com/files/public/api/resources/download?hash=abc123"
|
||||
if got != want {
|
||||
t.Fatalf("PublicShareURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,7 @@ type Share struct {
|
||||
ShareColumns
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
UserID uint64 `json:"userID,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
UserDownloads map[string]int `json:"userDownloads,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
SourcePath string `json:"sourcePath,omitempty"`
|
||||
|
||||
@@ -5,8 +5,9 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// currentSchemaVersion is the SQLite schema marker for this codebase.
|
||||
const currentSchemaVersion = 3
|
||||
// currentSchemaVersion is the SQLite schema marker for this codebase (version 1).
|
||||
// BoltDB has no schema_version; importing via cmd/migrate builds this SQLite shape directly.
|
||||
const currentSchemaVersion = 1
|
||||
|
||||
// Schema creates all tables for the SQLite database
|
||||
func createSchema(db *sql.DB) error {
|
||||
@@ -38,6 +39,7 @@ func createSchema(db *sql.DB) error {
|
||||
expire INTEGER NOT NULL DEFAULT 0,
|
||||
downloads INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT,
|
||||
token TEXT,
|
||||
user_downloads TEXT,
|
||||
share_settings TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 0
|
||||
@@ -77,8 +79,7 @@ func createSchema(db *sql.DB) error {
|
||||
-- Hashed tokens (minimal JWT → owner user_id)
|
||||
CREATE TABLE IF NOT EXISTS hashed_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
is_session INTEGER NOT NULL DEFAULT 0
|
||||
user_id TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hashed_tokens_user_id ON hashed_tokens(user_id);
|
||||
|
||||
@@ -182,14 +183,6 @@ func runMigrations(db *sql.DB, fromVersion int) error {
|
||||
switch v {
|
||||
case 1:
|
||||
// Canonical schema is createSchema + Bolt import; no step migrations.
|
||||
case 2:
|
||||
if err := normalizeLegacyShareTokens(db); err != nil {
|
||||
return err
|
||||
}
|
||||
case 3:
|
||||
if err := addHashedTokenSessionColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown schema version: %d", v)
|
||||
}
|
||||
@@ -203,49 +196,3 @@ func runMigrations(db *sql.DB, fromVersion int) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addHashedTokenSessionColumn adds the is_session flag to pre-existing
|
||||
// hashed_tokens tables. Existing rows are treated as non-session (API) tokens.
|
||||
func addHashedTokenSessionColumn(db *sql.DB) error {
|
||||
hasColumn, err := tableHasColumn(db, "hashed_tokens", "is_session")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasColumn {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.Exec(`ALTER TABLE hashed_tokens ADD COLUMN is_session INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return fmt.Errorf("failed to add hashed_tokens.is_session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tableHasColumn reports whether table/column exists in the current schema.
|
||||
func tableHasColumn(db *sql.DB, table, column string) (bool, error) {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to inspect table %s: %w", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
ctype string
|
||||
notNull int
|
||||
dfltValue sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬Null, &dfltValue, &pk); err != nil {
|
||||
return false, fmt.Errorf("failed to scan table_info(%s): %w", table, err)
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, fmt.Errorf("error iterating table_info(%s): %w", table, err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -3,14 +3,9 @@ package sqldb
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrSettingNotFound is returned by GetSetting when the key has no persisted row.
|
||||
// Callers must treat this as absence, not as an I/O failure.
|
||||
var ErrSettingNotFound = errors.New("setting not found")
|
||||
|
||||
// Settings SQL operations
|
||||
|
||||
// GetSetting retrieves a setting by key
|
||||
@@ -21,7 +16,7 @@ func (s *SQLStore) GetSetting(key string) ([]byte, error) {
|
||||
err := s.db.QueryRow(query, key).Scan(&value)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("%w: %s", ErrSettingNotFound, key)
|
||||
return nil, fmt.Errorf("setting not found: %s", key)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get setting: %w", err)
|
||||
|
||||
@@ -40,7 +40,9 @@ func scanShareUserID(s string, dest *uint64) error {
|
||||
|
||||
// GetShareByHash retrieves a share by hash
|
||||
func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE hash = ?`
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE hash = ?`
|
||||
|
||||
var link share.Share
|
||||
var userIDStr string
|
||||
@@ -54,6 +56,7 @@ func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
|
||||
&link.Expire,
|
||||
&link.Downloads,
|
||||
&link.PasswordHash,
|
||||
&link.Token,
|
||||
&userDownloadsJSON,
|
||||
&shareSettingsJSON,
|
||||
&link.Version,
|
||||
@@ -84,7 +87,9 @@ func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
|
||||
// GetSharesByUserID retrieves all non-expired shares for an owner user id.
|
||||
func (s *SQLStore) GetSharesByUserID(userID uint64) ([]*share.Share, error) {
|
||||
now := time.Now().Unix()
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE user_id = ? AND (expire = 0 OR expire > ?)
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE user_id = ? AND (expire = 0 OR expire > ?)
|
||||
ORDER BY path`
|
||||
|
||||
rows, err := s.db.Query(query, shareUserIDDB(userID), now)
|
||||
@@ -98,7 +103,9 @@ func (s *SQLStore) GetSharesByUserID(userID uint64) ([]*share.Share, error) {
|
||||
|
||||
// GetSharesBySourcePath retrieves shares for a specific source and path
|
||||
func (s *SQLStore) GetSharesBySourcePath(source, path string) ([]*share.Share, error) {
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? ORDER BY hash`
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE source = ? AND path = ? ORDER BY hash`
|
||||
|
||||
rows, err := s.db.Query(query, source, path)
|
||||
if err != nil {
|
||||
@@ -112,7 +119,9 @@ func (s *SQLStore) GetSharesBySourcePath(source, path string) ([]*share.Share, e
|
||||
// GetSharesBySourcePathUser retrieves shares for a specific source, path, and owner user id.
|
||||
func (s *SQLStore) GetSharesBySourcePathUser(source, path string, userID uint64) ([]*share.Share, error) {
|
||||
now := time.Now().Unix()
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? AND user_id = ?
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE source = ? AND path = ? AND user_id = ?
|
||||
AND (expire = 0 OR expire > ?) ORDER BY hash`
|
||||
|
||||
rows, err := s.db.Query(query, source, path, shareUserIDDB(userID), now)
|
||||
@@ -126,7 +135,9 @@ func (s *SQLStore) GetSharesBySourcePathUser(source, path string, userID uint64)
|
||||
|
||||
// GetPermanentShare retrieves a permanent share (expire = 0) for source, path, and owner.
|
||||
func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share.Share, error) {
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? AND user_id = ? AND expire = 0
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE source = ? AND path = ? AND user_id = ? AND expire = 0
|
||||
LIMIT 1`
|
||||
|
||||
var link share.Share
|
||||
@@ -141,6 +152,7 @@ func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share
|
||||
&link.Expire,
|
||||
&link.Downloads,
|
||||
&link.PasswordHash,
|
||||
&link.Token,
|
||||
&userDownloadsJSON,
|
||||
&shareSettingsJSON,
|
||||
&link.Version,
|
||||
@@ -171,7 +183,9 @@ func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share
|
||||
// ListAllShares retrieves all non-expired shares
|
||||
func (s *SQLStore) ListAllShares() ([]*share.Share, error) {
|
||||
now := time.Now().Unix()
|
||||
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE expire = 0 OR expire > ? ORDER BY path`
|
||||
query := `SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE expire = 0 OR expire > ? ORDER BY path`
|
||||
|
||||
rows, err := s.db.Query(query, now)
|
||||
if err != nil {
|
||||
@@ -193,59 +207,24 @@ func (s *SQLStore) SaveShare(link *share.Share) error {
|
||||
return fmt.Errorf("failed to marshal share settings: %w", err)
|
||||
}
|
||||
|
||||
hasLegacyToken, err := s.sharesHasLegacyTokenColumn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to inspect shares schema: %w", err)
|
||||
}
|
||||
|
||||
legacyToken := ""
|
||||
if hasLegacyToken {
|
||||
legacyToken, err = s.legacyShareToken(link.Hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var query string
|
||||
var args []any
|
||||
if hasLegacyToken {
|
||||
query = `INSERT OR REPLACE INTO shares
|
||||
query := `INSERT OR REPLACE INTO shares
|
||||
(hash, user_id, source, path, expire, downloads, password_hash,
|
||||
token, user_downloads, share_settings, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
args = []any{
|
||||
link.Hash,
|
||||
shareUserIDDB(link.UserID),
|
||||
link.SourcePath,
|
||||
link.Path,
|
||||
link.Expire,
|
||||
link.Downloads,
|
||||
link.PasswordHash,
|
||||
legacyToken,
|
||||
userDownloadsJSON,
|
||||
shareSettingsJSON,
|
||||
link.Version,
|
||||
}
|
||||
} else {
|
||||
query = `INSERT OR REPLACE INTO shares
|
||||
(hash, user_id, source, path, expire, downloads, password_hash,
|
||||
user_downloads, share_settings, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
args = []any{
|
||||
link.Hash,
|
||||
shareUserIDDB(link.UserID),
|
||||
link.SourcePath,
|
||||
link.Path,
|
||||
link.Expire,
|
||||
link.Downloads,
|
||||
link.PasswordHash,
|
||||
userDownloadsJSON,
|
||||
shareSettingsJSON,
|
||||
link.Version,
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(query, args...)
|
||||
_, err = s.db.Exec(query,
|
||||
link.Hash,
|
||||
shareUserIDDB(link.UserID),
|
||||
link.SourcePath,
|
||||
link.Path,
|
||||
link.Expire,
|
||||
link.Downloads,
|
||||
link.PasswordHash,
|
||||
link.Token,
|
||||
userDownloadsJSON,
|
||||
shareSettingsJSON,
|
||||
link.Version,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save share: %w", err)
|
||||
}
|
||||
@@ -320,6 +299,7 @@ func (s *SQLStore) scanShares(rows *sql.Rows) ([]*share.Share, error) {
|
||||
&link.Expire,
|
||||
&link.Downloads,
|
||||
&link.PasswordHash,
|
||||
&link.Token,
|
||||
&userDownloadsJSON,
|
||||
&shareSettingsJSON,
|
||||
&link.Version,
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const shareSelectColumns = `hash, user_id, source, path, expire, downloads,
|
||||
password_hash, user_downloads, share_settings, version`
|
||||
|
||||
func sharesTableHasColumn(db *sql.DB, column string) (bool, error) {
|
||||
rows, err := db.Query(`PRAGMA table_info(shares)`)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read shares schema: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
colType string
|
||||
notnull int
|
||||
dfltValue sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &colType, ¬null, &dfltValue, &pk); err != nil {
|
||||
return false, fmt.Errorf("scan shares schema: %w", err)
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, fmt.Errorf("iterate shares schema: %w", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// normalizeLegacyShareTokens keeps rollback compatibility with releases that still
|
||||
// scan shares.token into a Go string (NULL is unsupported there).
|
||||
func normalizeLegacyShareTokens(db *sql.DB) error {
|
||||
hasToken, err := sharesTableHasColumn(db, "token")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasToken {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE shares SET token = '' WHERE token IS NULL`); err != nil {
|
||||
return fmt.Errorf("normalize legacy share tokens: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) sharesHasLegacyTokenColumn() (bool, error) {
|
||||
return sharesTableHasColumn(s.db, "token")
|
||||
}
|
||||
|
||||
func (s *SQLStore) legacyShareToken(hash string) (string, error) {
|
||||
hasToken, err := s.sharesHasLegacyTokenColumn()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasToken {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var token sql.NullString
|
||||
err = s.db.QueryRow(`SELECT token FROM shares WHERE hash = ?`, hash).Scan(&token)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read legacy share token: %w", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
return "", nil
|
||||
}
|
||||
return token.String, nil
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
)
|
||||
|
||||
func createLegacySharesDB(t *testing.T) *SQLStore {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy-shares.db")
|
||||
store, _, err := NewSQLStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLStore: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.db.Exec(`DROP TABLE shares`)
|
||||
if err != nil {
|
||||
t.Fatalf("drop shares: %v", err)
|
||||
}
|
||||
_, err = store.db.Exec(`
|
||||
CREATE TABLE shares (
|
||||
hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
expire INTEGER NOT NULL DEFAULT 0,
|
||||
downloads INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT,
|
||||
token TEXT,
|
||||
user_downloads TEXT,
|
||||
share_settings TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 0
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create legacy shares table: %v", err)
|
||||
}
|
||||
_, err = store.db.Exec(`
|
||||
INSERT INTO shares (
|
||||
hash, user_id, source, path, expire, downloads, password_hash, token,
|
||||
user_downloads, share_settings, version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"legacy-null-token",
|
||||
"1",
|
||||
"/data",
|
||||
"/docs/file.txt",
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
nil,
|
||||
"{}",
|
||||
`{"shareType":"normal"}`,
|
||||
1,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert legacy share: %v", err)
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func scanLegacyShareToken(t *testing.T, store *SQLStore, hash string) string {
|
||||
t.Helper()
|
||||
var token string
|
||||
err := store.db.QueryRow(`
|
||||
SELECT hash, user_id, source, path, expire, downloads,
|
||||
password_hash, token, user_downloads, share_settings, version
|
||||
FROM shares WHERE hash = ?`, hash).Scan(
|
||||
new(string),
|
||||
new(string),
|
||||
new(string),
|
||||
new(string),
|
||||
new(int64),
|
||||
new(int),
|
||||
new(string),
|
||||
&token,
|
||||
new([]byte),
|
||||
new([]byte),
|
||||
new(int),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("scan legacy share row: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func TestMigrationNormalizesNullLegacyShareToken(t *testing.T) {
|
||||
store := createLegacySharesDB(t)
|
||||
|
||||
if err := runMigrations(store.db, 1); err != nil {
|
||||
t.Fatalf("runMigrations: %v", err)
|
||||
}
|
||||
|
||||
token := scanLegacyShareToken(t, store, "legacy-null-token")
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty string after migration", token)
|
||||
}
|
||||
|
||||
links, err := store.ListAllShares()
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllShares: %v", err)
|
||||
}
|
||||
if len(links) != 1 || links[0].Hash != "legacy-null-token" {
|
||||
t.Fatalf("unexpected shares loaded: %#v", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSharePreservesLegacyTokenColumn(t *testing.T) {
|
||||
store := createLegacySharesDB(t)
|
||||
if err := normalizeLegacyShareTokens(store.db); err != nil {
|
||||
t.Fatalf("normalizeLegacyShareTokens: %v", err)
|
||||
}
|
||||
|
||||
_, err := store.db.Exec(`UPDATE shares SET token = ? WHERE hash = ?`, "keep-me", "legacy-null-token")
|
||||
if err != nil {
|
||||
t.Fatalf("seed legacy token: %v", err)
|
||||
}
|
||||
|
||||
link, err := store.GetShareByHash("legacy-null-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetShareByHash: %v", err)
|
||||
}
|
||||
link.Downloads = 2
|
||||
|
||||
if err := store.SaveShare(link); err != nil {
|
||||
t.Fatalf("SaveShare: %v", err)
|
||||
}
|
||||
|
||||
token := scanLegacyShareToken(t, store, "legacy-null-token")
|
||||
if token != "keep-me" {
|
||||
t.Fatalf("token = %q, want keep-me", token)
|
||||
}
|
||||
|
||||
var tokenSQL sql.NullString
|
||||
if err := store.db.QueryRow(`SELECT token FROM shares WHERE hash = ?`, "legacy-null-token").Scan(&tokenSQL); err != nil {
|
||||
t.Fatalf("select token: %v", err)
|
||||
}
|
||||
if !tokenSQL.Valid {
|
||||
t.Fatal("expected legacy token column to remain non-null after save")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveShareSetsEmptyLegacyTokenForNewShare(t *testing.T) {
|
||||
store := createLegacySharesDB(t)
|
||||
if err := normalizeLegacyShareTokens(store.db); err != nil {
|
||||
t.Fatalf("normalizeLegacyShareTokens: %v", err)
|
||||
}
|
||||
|
||||
newShare := &share.Share{
|
||||
ShareSettings: share.ShareSettings{
|
||||
FrontendShareInfo: share.FrontendShareInfo{ShareType: "normal"},
|
||||
},
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: "brand-new-share",
|
||||
Path: "/new/file.txt",
|
||||
},
|
||||
SourcePath: "/data",
|
||||
UserID: 1,
|
||||
Version: 1,
|
||||
}
|
||||
if err := store.SaveShare(newShare); err != nil {
|
||||
t.Fatalf("SaveShare: %v", err)
|
||||
}
|
||||
|
||||
token := scanLegacyShareToken(t, store, "brand-new-share")
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty string for new share", token)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +1,21 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func execSaveRevokedToken(exec sqlExecutor, tokenHash string, revokedAt int64) error {
|
||||
query := `INSERT OR REPLACE INTO revoked_tokens (token_hash, revoked_at) VALUES (?, ?)`
|
||||
_, err := exec.Exec(query, tokenHash, revokedAt)
|
||||
// SaveRevokedToken adds a token hash to the revoked tokens table
|
||||
func (s *SQLStore) SaveRevokedToken(tokenHash string) error {
|
||||
query := `INSERT OR IGNORE INTO revoked_tokens (token_hash, revoked_at) VALUES (?, ?)`
|
||||
_, err := s.db.Exec(query, tokenHash, time.Now().Unix())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save revoked token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execDeleteHashedToken(exec sqlExecutor, tokenHash string) error {
|
||||
query := `DELETE FROM hashed_tokens WHERE token_hash = ?`
|
||||
_, err := exec.Exec(query, tokenHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete hashed token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execDeleteRevokedToken(exec sqlExecutor, tokenHash string) error {
|
||||
query := `DELETE FROM revoked_tokens WHERE token_hash = ?`
|
||||
_, err := exec.Exec(query, tokenHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete revoked token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type sqlExecutor interface {
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// HashedTokenRecord is a persisted bearer-token hash → owner mapping.
|
||||
type HashedTokenRecord struct {
|
||||
UserID uint64
|
||||
IsSession bool
|
||||
}
|
||||
|
||||
// PersistImmediateTokenRevocation records an immediate revocation and removes the
|
||||
// owner mapping in one transaction (revocation row first, then hashed_tokens).
|
||||
func (s *SQLStore) PersistImmediateTokenRevocation(tokenHash string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin immediate token revocation transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := execSaveRevokedToken(tx, tokenHash, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := execDeleteHashedToken(tx, tokenHash); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit immediate token revocation transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistTokenRetirement records a grace-window revocation and removes expired
|
||||
// revocation rows and owner mappings atomically.
|
||||
func (s *SQLStore) PersistTokenRetirement(tokenHash string, revokedAt int64, pruned []string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin token retirement transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := execSaveRevokedToken(tx, tokenHash, revokedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, hash := range pruned {
|
||||
if err := execDeleteHashedToken(tx, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := execDeleteRevokedToken(tx, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit token retirement transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveRevokedToken persists a revocation. revokedAt is the Unix timestamp of the
|
||||
// revocation; 0 marks an immediate revocation (no grace window).
|
||||
func (s *SQLStore) SaveRevokedToken(tokenHash string, revokedAt int64) error {
|
||||
return execSaveRevokedToken(s.db, tokenHash, revokedAt)
|
||||
}
|
||||
|
||||
// IsTokenRevoked checks if a token hash is in the revoked tokens table
|
||||
func (s *SQLStore) IsTokenRevoked(tokenHash string) (bool, error) {
|
||||
query := `SELECT 1 FROM revoked_tokens WHERE token_hash = ?`
|
||||
@@ -110,24 +30,22 @@ func (s *SQLStore) IsTokenRevoked(tokenHash string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetAllRevokedTokens retrieves all revoked token hashes mapped to their
|
||||
// revocation timestamp (0 = immediate revocation).
|
||||
func (s *SQLStore) GetAllRevokedTokens() (map[string]int64, error) {
|
||||
query := `SELECT token_hash, revoked_at FROM revoked_tokens`
|
||||
// GetAllRevokedTokens retrieves all revoked token hashes
|
||||
func (s *SQLStore) GetAllRevokedTokens() (map[string]struct{}, error) {
|
||||
query := `SELECT token_hash FROM revoked_tokens`
|
||||
rows, err := s.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get revoked tokens: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
revokedTokens := make(map[string]int64)
|
||||
revokedTokens := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var tokenHash string
|
||||
var revokedAt int64
|
||||
if err := rows.Scan(&tokenHash, &revokedAt); err != nil {
|
||||
if err := rows.Scan(&tokenHash); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan revoked token: %w", err)
|
||||
}
|
||||
revokedTokens[tokenHash] = revokedAt
|
||||
revokedTokens[tokenHash] = struct{}{}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -139,15 +57,18 @@ func (s *SQLStore) GetAllRevokedTokens() (map[string]int64, error) {
|
||||
|
||||
// DeleteRevokedToken removes a token hash from the revoked tokens table
|
||||
func (s *SQLStore) DeleteRevokedToken(tokenHash string) error {
|
||||
return execDeleteRevokedToken(s.db, tokenHash)
|
||||
query := `DELETE FROM revoked_tokens WHERE token_hash = ?`
|
||||
_, err := s.db.Exec(query, tokenHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete revoked token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveHashedToken saves a token hash to owner user_id mapping (decimal text).
|
||||
// isSession records that the bearer token is a FileBrowser session token rather
|
||||
// than a named API token, so auth can reject non-session tokens without caps.
|
||||
func (s *SQLStore) SaveHashedToken(tokenHash string, userID uint64, isSession bool) error {
|
||||
query := `INSERT OR REPLACE INTO hashed_tokens (token_hash, user_id, is_session) VALUES (?, ?, ?)`
|
||||
_, err := s.db.Exec(query, tokenHash, strconv.FormatUint(userID, 10), isSession)
|
||||
func (s *SQLStore) SaveHashedToken(tokenHash string, userID uint64) error {
|
||||
query := `INSERT OR REPLACE INTO hashed_tokens (token_hash, user_id) VALUES (?, ?)`
|
||||
_, err := s.db.Exec(query, tokenHash, strconv.FormatUint(userID, 10))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save hashed token: %w", err)
|
||||
}
|
||||
@@ -168,27 +89,26 @@ func (s *SQLStore) GetUserIDByTokenHash(tokenHash string) (uint64, error) {
|
||||
return strconv.ParseUint(idStr, 10, 64)
|
||||
}
|
||||
|
||||
// GetAllHashedTokens retrieves all token hash → owner mappings.
|
||||
func (s *SQLStore) GetAllHashedTokens() (map[string]HashedTokenRecord, error) {
|
||||
query := `SELECT token_hash, user_id, is_session FROM hashed_tokens`
|
||||
// GetAllHashedTokens retrieves all token hash → owner user_id mappings.
|
||||
func (s *SQLStore) GetAllHashedTokens() (map[string]uint64, error) {
|
||||
query := `SELECT token_hash, user_id FROM hashed_tokens`
|
||||
rows, err := s.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get hashed tokens: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
hashedTokens := make(map[string]HashedTokenRecord)
|
||||
hashedTokens := make(map[string]uint64)
|
||||
for rows.Next() {
|
||||
var tokenHash, idStr string
|
||||
var isSession bool
|
||||
if err := rows.Scan(&tokenHash, &idStr, &isSession); err != nil {
|
||||
if err := rows.Scan(&tokenHash, &idStr); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan hashed token: %w", err)
|
||||
}
|
||||
uid, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user_id in hashed_tokens: %w", err)
|
||||
}
|
||||
hashedTokens[tokenHash] = HashedTokenRecord{UserID: uid, IsSession: isSession}
|
||||
hashedTokens[tokenHash] = uid
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -200,7 +120,12 @@ func (s *SQLStore) GetAllHashedTokens() (map[string]HashedTokenRecord, error) {
|
||||
|
||||
// DeleteHashedToken removes a token hash mapping
|
||||
func (s *SQLStore) DeleteHashedToken(tokenHash string) error {
|
||||
return execDeleteHashedToken(s.db, tokenHash)
|
||||
query := `DELETE FROM hashed_tokens WHERE token_hash = ?`
|
||||
_, err := s.db.Exec(query, tokenHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete hashed token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteHashedTokensByUserID removes all token hashes for an owner user id.
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrationAddsHashedTokenSessionColumn(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy-tokens.db")
|
||||
store, _, err := NewSQLStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
// Recreate the pre-v3 hashed_tokens table without is_session.
|
||||
if _, err = store.db.Exec(`DROP TABLE hashed_tokens`); err != nil {
|
||||
t.Fatalf("drop hashed_tokens: %v", err)
|
||||
}
|
||||
if _, err = store.db.Exec(`CREATE TABLE hashed_tokens (token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL)`); err != nil {
|
||||
t.Fatalf("create legacy hashed_tokens: %v", err)
|
||||
}
|
||||
if _, err = store.db.Exec(`INSERT INTO hashed_tokens (token_hash, user_id) VALUES ('legacy-hash', '7')`); err != nil {
|
||||
t.Fatalf("insert legacy hashed token: %v", err)
|
||||
}
|
||||
if _, err = store.db.Exec(`UPDATE schema_version SET version = 2`); err != nil {
|
||||
t.Fatalf("set schema version: %v", err)
|
||||
}
|
||||
|
||||
if err = runMigrations(store.db, 2); err != nil {
|
||||
t.Fatalf("runMigrations: %v", err)
|
||||
}
|
||||
|
||||
if err = store.SaveHashedToken("new-hash", 8, true); err != nil {
|
||||
t.Fatalf("SaveHashedToken after migration: %v", err)
|
||||
}
|
||||
records, err := store.GetAllHashedTokens()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllHashedTokens: %v", err)
|
||||
}
|
||||
legacy, ok := records["legacy-hash"]
|
||||
if !ok || legacy.UserID != 7 {
|
||||
t.Fatalf("legacy mapping = %+v, ok=%v; want user 7", legacy, ok)
|
||||
}
|
||||
if legacy.IsSession {
|
||||
t.Fatal("legacy rows must migrate as non-session tokens")
|
||||
}
|
||||
if !records["new-hash"].IsSession {
|
||||
t.Fatal("session flag was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSettingMissingReturnsSentinel(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "settings.db")
|
||||
store, _, err := NewSQLStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
if _, err := store.GetSetting("does.not.exist"); !errors.Is(err, ErrSettingNotFound) {
|
||||
t.Fatalf("GetSetting missing error = %v, want ErrSettingNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -26,5 +26,5 @@ func HasAnyGlobalPermission(p Permissions) bool {
|
||||
// IsMinimalApiToken reports whether the token inherits the owner's globals at auth time
|
||||
// rather than embedding its own global permission caps in the JWT.
|
||||
func IsMinimalApiToken(t AuthToken) bool {
|
||||
return !HasAnyGlobalPermission(t.Permissions)
|
||||
return t.BelongsTo == 0 || !HasAnyGlobalPermission(t.Permissions)
|
||||
}
|
||||
@@ -41,10 +41,10 @@ func TestIsMinimalApiToken(t *testing.T) {
|
||||
if !IsMinimalApiToken(AuthToken{}) {
|
||||
t.Fatal("empty token should be minimal")
|
||||
}
|
||||
if IsMinimalApiToken(AuthToken{Permissions: Permissions{Admin: true}}) {
|
||||
if IsMinimalApiToken(AuthToken{BelongsTo: 1, Permissions: Permissions{Admin: true}}) {
|
||||
t.Fatal("custom token with global caps should not be minimal")
|
||||
}
|
||||
if !IsMinimalApiToken(AuthToken{Permissions: Permissions{Modify: true}}) {
|
||||
if !IsMinimalApiToken(AuthToken{BelongsTo: 1, Permissions: Permissions{Modify: true}}) {
|
||||
t.Fatal("token without global caps should be minimal")
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ type AuthToken struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
BelongsTo uint64 `json:"belongsTo,omitempty"` // legacy stored metadata only; identity is hashed_tokens lookup
|
||||
BelongsTo uint64 `json:"belongsTo,omitempty"` // numeric user id in JWT claims (bolt-era small ids still work)
|
||||
IssuedAt int64 `json:"issuedAt,omitempty"`
|
||||
ExpiresAt int64 `json:"expiresAt,omitempty"`
|
||||
Permissions Permissions `json:"Permissions,omitempty"`
|
||||
@@ -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))])
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
@@ -114,36 +112,6 @@ func AddApiToken(tokenString string, userID uint64) error {
|
||||
return accessDb.AddApiToken(tokenString, userID)
|
||||
}
|
||||
|
||||
// RegisterSessionToken maps a session bearer JWT to its owner user id.
|
||||
func RegisterSessionToken(tokenString string, userID uint64) error {
|
||||
if accessDb == nil {
|
||||
return fmt.Errorf("access storage not available")
|
||||
}
|
||||
return accessDb.AddSessionToken(tokenString, userID)
|
||||
}
|
||||
|
||||
// RetireSessionToken schedules a rotated session token for revocation after a
|
||||
// short grace window so in-flight requests carrying the old cookie still work.
|
||||
func RetireSessionToken(tokenString string) error {
|
||||
if accessDb == nil {
|
||||
return fmt.Errorf("access storage not available")
|
||||
}
|
||||
return accessDb.RetireToken(tokenString)
|
||||
}
|
||||
|
||||
// HashedTokenOwner returns the owner id and session type for a registered bearer
|
||||
// token hash. ok is false when the hash has no mapping (unknown or already revoked).
|
||||
func HashedTokenOwner(tokenString string) (userID uint64, isSession bool, ok bool) {
|
||||
if accessDb == nil {
|
||||
return 0, false, false
|
||||
}
|
||||
info, found := accessDb.GetHashedTokenInfo(tokenString)
|
||||
if !found {
|
||||
return 0, false, false
|
||||
}
|
||||
return info.UserID, info.IsSession, true
|
||||
}
|
||||
|
||||
func RemoveApiToken(tokenString string) error {
|
||||
return accessDb.RemoveApiToken(tokenString)
|
||||
}
|
||||
|
||||
Loaded 100 of 394 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user