Compare commits

..
12 Commits
Author SHA1 Message Date
Graham Steffaniak 8f6c872ee7 hide replace option for users without modify permission (#2966) 2026-09-17 15:14:30 -05:00
Graham Steffaniak 79de9204b5 Merge branch 'dev/v2.0.7' of github.com:gtsteffaniak/filebrowser into dev/v2.0.7 2026-09-17 15:04:06 -05:00
Graham Steffaniakandzumbiepig 07a297a937 Update deps v2.0.7 (#2965)
Co-authored-by: zumbiepig <121742281+zumbiepig@users.noreply.github.com>
2026-09-17 15:01:49 -05:00
Graham Steffaniak 012e514017 Merge branch 'dev/v2.0.7' of github.com:gtsteffaniak/filebrowser into dev/v2.0.7 2026-09-17 14:38:16 -05:00
Graham Steffaniakandzumbiepig f654699b99 Backport risc v release (#2964)
Co-authored-by: zumbiepig <121742281+zumbiepig@users.noreply.github.com>
2026-09-17 14:38:10 -05:00
Graham Steffaniak ae7b2db598 Merge branch 'dev/v2.0.7' of github.com:gtsteffaniak/filebrowser into dev/v2.0.7 2026-09-17 14:37:02 -05:00
Graham Steffaniak 214683ae2e fix a few usage reporting bugs (#2963) 2026-09-17 14:36:43 -05:00
Graham Steffaniak f03534db06 Merge branch 'main' into dev/v2.0.7 2026-09-17 13:54:06 -05:00
Graham Steffaniak b07300a57d Popup debounce (#2961) 2026-09-16 18:49:44 -05:00
Graham Steffaniak 42f82811e7 detect more platform types (#2952) 2026-09-16 18:38:22 -05:00
Matt Van HornandMatt Van Horn 6fffdd3840 fix: preserve Ctrl-click file selection when keyboard state is stale (#2958)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-09-16 18:11:35 -05:00
Graham Steffaniak 7aebbfe04c updated changelog 2026-09-12 18:44:27 -05:00
811 changed files with 18168 additions and 86033 deletions

No files matched your search

-15
View File
@@ -1,15 +0,0 @@
root = true
[*]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
[*.go]
indent_size = 4
[{Makefile,makefile}]
indent_style = tab
@@ -1,101 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_REPO="${IMAGE_REPO:-gtstef/filebrowser}"
SLIM_SUFFIX="${SLIM_SUFFIX:-}"
if [[ -z "${GITHUB_OUTPUT:-}" ]]; then
echo "GITHUB_OUTPUT is not set" >&2
exit 1
fi
write_outputs() {
local update_track="$1"
local update_major="$2"
echo "update_track=${update_track}" >> "$GITHUB_OUTPUT"
echo "update_major=${update_major}" >> "$GITHUB_OUTPUT"
}
registry_version() {
local tag="$1"
local ver err err_file
err_file="$(mktemp)"
if ! ver="$(docker buildx imagetools inspect "${IMAGE_REPO}:${tag}" \
--format '{{ with index .Image "linux/amd64" }}{{ index .Config.Labels "org.opencontainers.image.version" }}{{ end }}' 2>"$err_file")"; then
err="$(cat "$err_file")"
rm -f "$err_file"
if grep -qiE 'not found|manifest unknown' <<<"$err"; then
ver=""
else
echo "Failed to inspect ${IMAGE_REPO}:${tag}: ${err}" >&2
exit 1
fi
else
rm -f "$err_file"
if [[ -z "$ver" ]]; then
echo "Missing org.opencontainers.image.version on ${IMAGE_REPO}:${tag} (linux/amd64)" >&2
exit 1
fi
fi
ver="${ver#v}"
printf '%s' "$ver"
}
semver_allows_update() {
local new="$1"
local incumbent="$2"
if [[ -z "$incumbent" ]]; then
return 0
fi
local winner
winner="$(printf '%s\n' "$incumbent" "$new" | sort -V | tail -n1)"
[[ "$winner" == "$new" ]]
}
ref="${GITHUB_REF_NAME:-}"
ref="${ref#v}"
if [[ ! "$ref" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then
echo "Tag '${GITHUB_REF_NAME:-}' has no X.Y.Z-suffix form; leaving major-only floats enabled, track floats disabled"
write_outputs false true
exit 0
fi
new_version="$ref"
semver_core="${BASH_REMATCH[1]}"
suffix="${BASH_REMATCH[2]}"
major="$(echo "$semver_core" | cut -d. -f1)"
track=""
case "$suffix" in
beta) track="beta" ;;
stable) track="stable" ;;
*)
echo "Tag suffix '${suffix}' is not beta/stable; leaving major-only floats enabled, track floats disabled"
write_outputs false true
exit 0
;;
esac
track_tag="${track}${SLIM_SUFFIX}"
major_tag="${major}-${track}${SLIM_SUFFIX}"
incumbent_track="$(registry_version "$track_tag")"
incumbent_major="$(registry_version "$major_tag")"
update_track=false
if semver_allows_update "$new_version" "$incumbent_track"; then
update_track=true
fi
update_major=false
if semver_allows_update "$new_version" "$incumbent_major"; then
update_major=true
fi
echo "Floating tag policy (${SLIM_SUFFIX:-full} image):"
echo " New version: ${new_version}"
echo " Track tag: ${track_tag} (incumbent: ${incumbent_track:-<none>}) -> update_track=${update_track}"
echo " Major tag: ${major_tag} (incumbent: ${incumbent_major:-<none>}) -> update_major=${update_major}"
write_outputs "$update_track" "$update_major"
-16
View File
@@ -88,8 +88,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 +124,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
+2 -12
View File
@@ -8,7 +8,6 @@ on:
jobs:
test-backend:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -18,19 +17,15 @@ jobs:
run: go test -race -v ./... -timeout 30s
lint-backend:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- 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
- working-directory: backend
run: go tool golangci-lint run --path-prefix=backend
format-backend:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -40,7 +35,6 @@ jobs:
run: go fmt ./...
lint-frontend:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -50,7 +44,6 @@ jobs:
run: npm i && npm run lint
test-frontend:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -60,7 +53,6 @@ jobs:
run: npm i && npm run test
check-translations:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -70,7 +62,6 @@ jobs:
run: npm i && npm run i18n:check
check-docs:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -80,7 +71,6 @@ jobs:
- run: git diff --exit-code
check-icons:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
+18 -34
View File
@@ -61,23 +61,18 @@ jobs:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Decide floating Docker tags
id: floating
run: bash .github/scripts/docker-floating-tag-policy.sh
- name: Modify tags (strip 'v' and add version tags)
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq \
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not)
) |
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor (always) and major tags (when allowed)
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor and major tags
.tags += [
.tags[]
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
@@ -88,20 +83,18 @@ jobs:
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
if $updateMajor then
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
else empty end
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
] |
# Add suffix-only tags for beta when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta")) then
# Add suffix-only tags for beta (only if tag ends with -beta)
if any(.tags[]; split(":")[-1] | endswith("-beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta"
]
else . end |
# Add suffix-only tags for stable (and latest) when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable")) then
# Add suffix-only tags for stable (and latest) (only if tag ends with -stable)
if any(.tags[]; split(":")[-1] | endswith("-stable")) then
.tags += [
"gtstef/filebrowser:stable",
"ghcr.io/gtsteffaniak/filebrowser:stable",
@@ -153,26 +146,19 @@ jobs:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Decide floating Docker tags
id: floating
env:
SLIM_SUFFIX: -slim
run: bash .github/scripts/docker-floating-tag-policy.sh
- name: Modify tags (strip 'v' and add 'slim' if needed)
id: modify-json-slim
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq \
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not) # Remove the "latest" tag
| . + "-slim" # Append "-slim" to the versioned tag
) |
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor (always) and major tags (when allowed)
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor and major tags
.tags += [
.tags[]
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
@@ -183,20 +169,18 @@ jobs:
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
if $updateMajor then
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
else empty end
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
] |
# Add suffix-only tags for beta-slim when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
# Add suffix-only tags for beta-slim (only if tag ends with -beta-slim)
if any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
.tags += [
"gtstef/filebrowser:beta-slim",
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
]
else . end |
# Add suffix-only tags for stable-slim when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
# Add suffix-only tags for stable-slim (only if tag ends with -stable-slim)
if any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
.tags += [
"gtstef/filebrowser:stable-slim",
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
+1 -12
View File
@@ -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
@@ -46,7 +36,6 @@ frontend/.tmp-icons-*
/backend/internal/web/dist
/backend/internal/web/embed
/backend/debug
/backend/http
.DS_Store
node_modules
@@ -69,7 +58,7 @@ yarn-error.log*
*.sln
*.sw*
bin/
/build/
build/
*__debug*
backend/custom.css
+10 -255
View File
@@ -2,258 +2,14 @@
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.1.4
**Security**:
- [Low] `GET /api/settings` and `GET /api/settings?property=auth` no longer return cleartext secrets (`auth.key`, `totpSecret`, `adminPassword`, OIDC `clientSecret`, LDAP bind password, recaptcha secret, OnlyOffice secret); secret fields are replaced with `**hidden**` in the response, and the YAML export now redacts the recaptcha and LDAP secrets whose comments lacked the `secret:` marker (GHSA-892f-9h5p-mfc8) reporter @paulmossman
**New Features**:
- feat(search): sidebar File actions and close button in advanced search (#3303) (#2479) (#3303)
**Bug Fixes**:
- Deleting a directory now releases folder quotas rooted inside it instead of leaving them charged for files that no longer exist; applies to API and WebDAV deletes (#3163) (#3235)
- Bulk and public share deletes now release accounted quota usage; nested folder quotas reset when an ancestor is removed (#3163) (#3305)
- Directory listings skip entries that return permission denied or not-found on per-entry stat instead of failing the whole folder (e.g. unreadable NTFS junctions on CIFS); skipped entries are omitted from the index like `ls -l` (#3184)
- Preview generation for webp broken (#3254) (#3259)
- Share banner never shows to public visitors, returns bannerUrl but not banner (#3246) (#3250)
- fix html/markdown relative file preview support (#3131) (#3129) (#2355)
- fix(archive): extract tar hard links instead of dropping them (#3234)
- fix(auth): apply stored permission caps for customized API tokens with no global flags enabled (#3255)
- Archive extraction writes regular and hard-link entries at the entry path instead of following an existing symlink at that path (tar and zip) (#3265)
- fix(move-copy): require choosing a destination source in advanced search when none can be inferred, instead of silently copying to the source root (#3276)
- fix(stream): single-file shares can now stream their own media on Windows by using slash-based path handling (#3270) (#3271)
- fix(users): `lockPassword` is now enforced on self-service password changes; non-admins get 403 while admin resets still work (#3279) (#3283)
- fix(analytics): the FreeBSD release binary now reports `freebsd` in `system.os` instead of `other` (#3244) (#3284)
- fix(frontend): share expiration now reads backend Unix-seconds timestamps correctly instead of showing dates decades off (#3247)
- fix(frontend): video on iPhone now uses the native fullscreen player instead of the in-page fallback (#3113) (#3288)
- fix(frontend): the video loading spinner no longer stays on for the rest of playback when the fetch stalls but the buffer can continue (#3287) (#3289)
- fix(frontend): the share banner is now shown to public visitors, using `bannerUrl` instead of the never-set `banner` field (#3246) (#3250)
- fix(preview): camera RAW files (Panasonic `.rw2`/`.raw`/`.rwl`, Olympus `.orf`, `.cr2`/`.dng`) extract embedded previews from TIFF strips/SubIFDs and no longer return 415 when EXIF decoding fails (#3219) (#3243)
- fix(indexing): restore lazy listing persistence with atomic folder-size deltas and ancestor propagation, and force a full scan for scanners that missed their slot while the process was down (#3260)
- fix(indexing): directory listings skip entries that cannot be accessed or have vanished instead of failing the whole listing; remaining errors still fail and fully-vanished nonempty directories return an error (#3292)
- fix(frontend): redirects from `/files` stay inside the files area so a source named "Files" no longer leaves an empty view after login (#3167) (#3304)
## v2.1.3
**Security**:
- [Moderate] Passkey registration and deletion now require the account password when a passkey is already configured, and passkeys cannot be altered through generic user profile PATCH (GHSA-r744-4vm3-2px3) reporter @tonghuaroot
- [Medium] Storage quota accounting now records upload byte deltas using the pre-replace file size, and upload paths without size metadata or with resumed chunk offsets cannot skip quota checks (GHSA-m57f-66qj-8jxg) reporter @jayhemnani9910
- [High] Sources marked `readOnly` are now enforced on WebDAV writes, server-side archive/unarchive, and OnlyOffice save callbacks (GHSA-4v62-884x-2466) reporter @anpryl
- [Moderate] Moving or renaming a folder updates only shares whose paths are under the moved prefix, preserving per-file share roots instead of retargeting unrelated substring matches (GHSA-26fh-r34c-qxh6) reporters @jayhemnani9910 @dylanzonix
- [Moderate] WebDAV PUT now requires Create permission when the target path does not yet exist, so Modify-only users cannot create new files (GHSA-8655-2ph3-q7q7) reporter @levanduy181
- [Low] Failed-login lockout and auth rate limits now use the rightmost `X-Forwarded-For` hop when proxy headers are trusted, so a client cannot spoof the client IP with a leftmost forwarded value (GHSA-wjp8-g49g-xrhx) reporter @LECOQQ
- [Low] Duplicate finder results are cached per requesting user so filtered file names from one account are not served to another (GHSA-4fhx-8pf6-5gmr) reporter @Nazarami
- [Moderate] Authenticated `GET /api/resources/view` no longer accepts view tokens minted for a public share when the caller lacks view permission on the requested source (GHSA-wxj5-g3qp-g732) reporter @BlueN0r
- [Low] SSE source-update and user-targeted delivery hold the client registry lock through nonblocking sends, preventing concurrent map mutation and send-on-closed-channel panics when Realtime clients connect, disconnect, or shut down (GHSA-p9wm-f5jj-75pj) reporter @tao0845
- [High] Incomplete symlink-containment remediation: an authenticated non-admin user could create a relative symlink via archive extraction, relocate it with WebDAV MOVE, and write through it to arbitrary host paths with the service account's privileges (GHSA-3j46-5w28-f8p2) reporter @Saku0512
- [Moderate] Source-root containment bypass: create, write, delete, and move operations now resolve symlinks uncached and enforce the caller's scope, closing escapes through pre-existing out-of-source symlinks on upload, PUT, PATCH, delete, and WebDAV endpoints (GHSA-j764-qg3m-rhv7) reporter @chaoming0625
- [Moderate] Preview reads resolve symlinks within the caller's scope so authenticated preview cannot follow an out-of-scope symlink target (GHSA-g2q5-r6ch-v25f) reporter @tao0845
- [Moderate] Archive downloads and extraction no longer read or write through symlinks that resolve outside the caller's share or user scope; escaping archive-walk entries are skipped (GHSA-qqf3-2wvq-q4w9) reporter @SmokinCaterpillar
- [Moderate] Share directory-archive downloads now apply the share owner's access rules to every entry, closing a bypass where anonymous share visitors could download admin-denied content (GHSA-9fcr-gm58-p839) reporter @Tan-JunWei
- [Moderate] Access rule paths are stored and matched case-insensitively so rules cannot be bypassed via path casing on case-insensitive filesystems; existing rules are migrated at startup (GHSA-4vrc-935w-xxq2) reporter @FrogApprentice
**New Features**:
- chunked uploads for large files like rclone `--owncloud-chunk-size` (#3112)
**Bug Fixes**:
- keep requested permissions when creating a user (#3130)
- add missing file modify logging to activity viewer (#3152)
- fix(frontend): restore upload prompt close, tab-reload, and in-app navigation guards while uploads are active (including paused uploads)
- fix(frontend): load the default source when browsing `/files/` with a single configured source ([#3178](https://github.com/gtsteffaniak/filebrowser/issues/3178)) (related: [#3143](https://github.com/gtsteffaniak/filebrowser/issues/3143), [#3144](https://github.com/gtsteffaniak/filebrowser/issues/3144))
- Path picker list and thumbnails not working in shares in the markdown editor (#3223).
- perf(indexing): single statx per Linux directory entry (#1890)
## v2.1.2
**Security**:
- [High] New accounts named with ".." breaks out of scoped folder on creation (GHSA-c84q-q5g6-r47f) -- reporter @dylanzonix
- [Medium] Fixed session token collision issue when multiple users login simultaneously. (GHSA-c9cr-w724-gqj3) (#3135) Thanks @dl-pt reporters: @Ishtiaque-Ahmed-Risat @AlexWhiplash @tonghuaroot
**Notes**:
- Removed "Editor quick save" setting (`userDefaults.fileViewer.quickSave`), the save button is now always present in the editor toolbar. (#3122)
- add Basque (eu) translation and fix Spanish (es) strings (#3118)
**Bug Fixes**:
- fix(webdav): OPTIONS now advertises only methods actually supported and permitted for the user; the DAV header reports class 1 only since LOCK/UNLOCK are not enabled (#3110)
- fix(download): revalidate single-file downloads so a changed file is not served stale (#3133)
- Language switcher not working (#3114) (#3117)
- Viewer showing a stale image after the file changed on disk: single-file downloads are now revalidated by the browser (#3132)
## v2.1.1
Note: this release also contains all changes from v2.0.10-beta changelog
**New Features**:
- User and group picker with partial, case-insensitive search when creating access rules (#3085), assigning groups on user edit, and choosing members on group edit.
- New `PUT /api/access/user-groups` endpoint that atomically replaces a user's group memberships.
- feat(search): configure the advanced search result limit (#3058) @beemines
- feat(listing): add sortable Date added and Type columns (#3052) @dweebzxx
- feat(archive): suggest and preview ZIP filename encodings (#3024) @AnaTofuZ
**Notes**:
- Group API breaking changes: `POST /api/access` returns 400 when `ruleCategory=group` names a nonexistent group or `ruleCategory=user` names a nonexistent user, group endpoints return 400/409 instead of 500 for client errors, and `PUT /api/access/group` accepts a `create` flag and returns `unknownMembers`. Existing database records are unaffected; no migration is required.
- update halloween theme (#3055)
- Add soft wrap option to the editor config (#3063)
- add better screenshot view handling (#3059)
- Update nl.json (#3089) @Stephan-P
- update german translations (#3062) @fehnomenal
- Update dev dependencies, add biome, fix types and linting (#3075) @Kurami32
**Bug Fixes**:
- Groups assigned in the user edit prompt now refresh the Groups settings list.
- Creating a group with an existing name now returns a conflict instead of silently overwriting its members.
- Access rules can no longer create empty groups from typos; group rules must reference an existing group.
- Empty group names are rejected and new group names must be 2-128 characters without control characters (existing records are unaffected); deleting or renaming a user now updates group memberships.
- fix(auth): use login endpoint for existing TOTP authentication (#3074)
- fix: apply theme toggles on anonymous public shares (#3049) @mvanhorn
- fix(frontend): keep source named files in extractSourceFromPath (#3099) (#3095)
- fix(auth): identify login fields for password autofill (#3077)
- fix(backend): skip denied paths in multi-file downloads, 404 when none remain (#3100) @hippi345
- fix: persist the user scope path chosen in the edit dialog (#3065) @mvanhorn
## v2.1.0
**Security**:
- [Moderate] Conflicting upload responses no longer synchronously drain the request body before returning HTTP 409; the server now closes the body and marks the connection for closure, preventing denial-of-service from clients that never send EOF (CWE-400).
- 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)
**New Features**:
- Added Storage Quotas
- configurable by folder, source, user, and share.
- Administrators can create, edit, view, and delete folder quotas through the interface.
- Quota information now appears in source views, share details, sidebar progress bars, and folder prompts.
- Enhancements to the editor (#2714):
- Added a floating button to open a resizable split view with live editing for markdown files, the scrolling is synced in both sides and scrolling in single-view modes (the viewer and editor in non-split) now also tries to sync with each other so you don't lose context easily.
- Added a toolbar: This toolbar is present on all the files.
- JSON files have a button to toggle between formatted and minified versions (#2854) (#2566).
- Markdown files have a richer toolbar with lots of quick actions, for example you can now browse, navigate and insert images more easily from your current source.
- Added a prompt to configure some editor settings in UI, that prompt is accessible via the toolbar in the three-dots menu, there you can configure things like:
- Word wrap, keybinds, autocompletion, scrollbar, etc.
- The markdown viewer now supports rendering LaTeX Math and Chemistry formulas.
- Improved and fixed some styles that weren't following the app light/dark theme (#2949).
- Proxy auth group support: `groupsClaim`, `adminGroup`, and `userGroups` now control role-based access when the proxy sends a group/role header (#2755). Admin is determined by `adminGroup` only.
- Moved `auth.adminUsername` and `auth.adminPassword` to `auth.methods.password` (legacy top-level keys are migrated automatically on load).
- Support for `.elrc` (word-by-word lyrics), `.vtt`, `.srt` sidecar files for lyrics in audio files in the media player. (#2838)
- Added setting to configure the placement of the prompts close button in profile settings (#2853) (#2595).
- Added "New file templates". You can now add and configure pre-defined filenames + extension for the creation of new files in the context menu on profile settings! (#2881) (#1239).
- sidebar links can be bulk edited as yaml (#1963) (settings > user management)
- admins can customize more defaults:
- share creation defaults and enforcements (#1692) (#2434) (#2279) (#2812) (settings > share management)
- sidebar link defaults and enforcements (#2561) (settings > user management)
- tool defaults and enforcement (settings > user management)
- Add cli init CLI command (#2957)
- Sort header in file listings inside prompts like move/copy/quick jump, is now sticky. Also added a button to show a search input and filter the items of the current listing (#3028).
- Quick jump now scrolls and highlights the current item you are previewing (#3028).
- When hovering `date` and `name` in the listings, will show up the formatted/raw date (the inverse of what you have configured to display) and the full filename (#3028) (#2623).
- User groups can now be managed from the WebUI under `User Management` -> `Groups` and when editing a user (#2983) (#3040).
**Notes**:
- An Admin can remove a `defautlEnabled` source for a user and it will remove removed until an admin adds it back.
- Enhancements to cross-source copy/move permissions and behavior.
- Improved resume upload behavior.
- Play/pause on videos in mobile now is toggled by the button in the middle rather than the whole container (#2828).
- improved video thumbnail generation speed and efficiency.
- Added icons based on extension to the upload prompt and new file/folder/rename prompts (#2881).
- Profile settings show minimal options by default. Full advanced profile settings can be shown by default by enabling `account.showAdvancedProfile`.
- sidebar links/navigation is button group toggle for clearer visibility
- 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
- Polished a bit some styles across various places of the UI (#3019) (#3028).
- Moved `Access Management` under `User Management` in the UI (#3040).
- improved developer experience with native vue webhook reloads.
- improved ffmpeg handling and concurrency via library upgrades
**Bug Fixes**:
- Undo in a fresh opened file on the editor was setting the file empty (#2714)
- Added some missing styles in the markdown viewer (#2714)
- Recaptcha not working (#1925) (#2861)
- Fix disk-usage overstatement on virtiofs bind mounts (#2894)
- Support non-ASCII share passwords (#2933)
- Fall back to buffered copies when FUSE rejects fast paths (#2938)
- Preserve deleted sidebar links across restarts (#2935)
- scope padding to listing view (#2934)
- 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)
- Context menu stuck when opened from a listing in a prompt like move/copy or search (#3028).
## v2.0.10
**Security**:
- [High] Restricted custom API tokens (`minimal=false`) can no longer be upgraded to a full-permission session via `POST /api/auth/renew`; renew accepts only web session tokens registered in `hashed_tokens` (GHSA-6gr6-5qpq-888p) -- thanks @tao0845.
- [High] TOTP re-enrollment via `POST /api/auth/otp/generate` and `POST /api/auth/otp/verify` no longer allows anonymous callers to replace an existing second factor using only the account password; reset or replace requires an authenticated self or admin session (first-time enrollment without MFA unchanged) (GHSA-qx86-4v5r-26g5) -- thanks @tao0845.
- [Low] Public share lyrics and subtitle media routes now honor the share's file-viewer setting, download disable flag, and download limits, matching the public download route (GHSA-p7x3-p5jj-9xfh) -- thanks Yves Soete of Blacksight LLC. @yssoe
**New Features**:
- Require password change at next login for password-based users: new user setting `requirePasswordChange` (user defaults + per-user admin toggle), login blocked until the user sets a new password; bootstrap admins with a generated initial password get this automatically ([#2977](https://github.com/gtsteffaniak/filebrowser/issues/2977)). Generated bootstrap passwords use a speakable `word-xxxxx-xx` form (random word from a fixed list plus a 5-character and 2-character code) instead of a long hex string, for easier handoff before the forced change.
- Config YAML expands `$VAR` and `${VAR}` so values such as `userPassword: "${FILEBROWSER_LDAP_USER_PASSWORD}"` work as documented (#3042).
**Notes**:
- Sidebar navigation tree rows are real hyperlinks: middle-click, Ctrl/Cmd+click, and Shift+click use the browser’s default new-tab or new-window behavior.
- Sidebar source links can now switch between aggregated usage (default) and a root-filesystem-only view via a new "Limit disk usage to source filesystem" toggle.
- [docker] upgraded ffmpeg from 9.0 to 9.0.2
- CLI setup command no longer produces full config, instead a minimal config.yaml with comments.
**Bugfixes**:
- Fixed slow or broken file listing when `http.baseURL` is a subpath (for example `/files/`): the default source redirect navigated to `/files/{source}` on top of the app base URL, producing `/files/files/{source}` and resolving the wrong storage source until the route recovered.
- Anonymous visitors on public shares could not play inline video or audio: `GET /public/api/media/stream` returned 403 because view grants were validated before share context was fully hydrated; playback now works when download and the file viewer are allowed ([#3041](https://github.com/gtsteffaniak/filebrowser/issues/3041)).
- Fixed inflated disk usage for sources spanning multiple ZFS datasets (or btrfs subvolumes): shared-pool filesystems are now grouped by pool and counted once, instead of multiplying capacity by the number of mounted datasets (#3025) (#2997) (#2761).
- LDAP `userGroups` matching now accepts CN-only config values against full `memberOf` DNs and compares case-insensitively, instead of failing the shared auth helper with strict string equality (#3044).
- OIDC session expires despite tokenExpirationHours (#3006).
- Media playback becomes stuck after opening a failing media file since v2.0.8-beta (#3031)
## v2.0.9
**Security**:
- Fresh install with default `admin`/`admin` startup generates a random initial password and logs it once (#2977). Updated documentation to follow.
- OIDC login binds OAuth `state` to an HttpOnly cookie (crypto-random) and rejects tampered callbacks; post-login redirects must be same-app relative paths.
- Login, logout, and session-expiry redirects reject open-redirect targets (`//`, `/\\`, off-site URLs); signup sends credentials in a JSON body instead of query parameters.
**Notes**:
- Webdav always shows hidden files, ignores user preference. (#3004)
- SQLite shared cache (`cache=shared`) removed from the application database connection: its table-lock conflicts bypass `busy_timeout` and fail immediately. WAL mode already allows concurrent readers.
**Bugfixes**:
- Fixed intermittent blank page / blocked inline SPA script after CSP hardening (#2995)
- ffmpeg unresponsive lock issue (#2996)
- fixed token migration lockout regression from v2.0.8-beta (#2999)
- Public shares with OnlyOffice disabled (`enableOnlyOffice=false`) now reject `/office/config` and `/office/callback` requests server-side instead of only hiding the editor in the UI
- Session/API token registrations now record the JWT expiry; expired mappings are pruned at startup, and tokens expired beyond the same 2-minute grace window used for session rotation no longer resolve a user identity on public share routes
- Startup now fails when the configured/env auth signing key differs from the key persisted in the application database, instead of warning and minting tokens with divergent keys
- OnlyOffice document downloads now re-validate redirect targets against the configured document-server host, closing an SSRF gap where a redirect could send the fetch to an internal address
- OnlyOffice "closed with changes" callbacks now keep the document key until the save succeeds, so a failed save can be retried by the document server instead of being rejected as an unknown session
- External JWT (JwtAuth) requests now reuse an existing valid session cookie instead of minting and registering a new session token on every request
- SQLite index and application databases configure `busy_timeout` on every pooled connection, and busy/locked detection now uses driver result codes; index batch writes and maintenance no longer report success when the DB stays busy, and index cache reads return busy errors instead of empty results so listing can fall back to the filesystem.
## 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.
- 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**:
@@ -333,7 +89,6 @@ Note: this release also contains all changes from v2.0.10-beta changelog
- 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
- Queue buttons not clickable in the desktop panel in audio files.
## v2.0.1
@@ -407,7 +162,7 @@ This version represents the most significant change to date. It **requires** bot
- opt-in feature to send deployment analytics to filebrowser quantum developer servers
- anonymized with a viewer so users can see what info would be sent.
- if opt-in, every month a snapshot of your deployment config would be sent to developer servers
- this will help me know what features are being used and what versions everyone is on over time. I will also provide a public dashboard with this information in the future.
- this will help me know what features are being used and what versions everyone is on over time. I will also provide a public dashboard with this information in the future.
- WebDAV now supports set modification time via the `X-OC-Mtime` header for clients that support it (#2626). See [WebDAV docs](https://filebrowserquantum.com/en/docs/features/webdav/).
- Copy operations now preserve their original modification times (#2642) (#2647):
- WebUI preserves both, files and directories.
@@ -587,14 +342,14 @@ This version represents the most significant change to date. It **requires** bot
- Improved style of drag and drop into listing view (#2407)
- Edit Sidebar links has new "show tools in sidebar" toggle and all users have this enabled by default. can be disabled via for new users `userDefaults.sidebar.showTools: false`
- Update user defaults ordering (#1140)
- Save view modes and sizes into local storage instead of db (#2301)
- Save view modes and sizes into local storage instead of db (#2301)
**BugFixes**:
- Blue overlay when using gestures in video files on mobile (#2360)
- Playback queue wasn't updating when changing of folder (#2360)
- Added missing `UserDefaults.listing.showCopyPath` option to config file (#2364)
- Delete prompt thumbnail didn't worked when using it with the `delete` shortcut. (#2407)
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
- Drag and drop style state was stuck until page reload (#2407)
- Navigate close settings shows "something went wrong" (#2047)
@@ -740,7 +495,7 @@ This version represents the most significant change to date. It **requires** bot
- Cannot edit shared file in OnlyOffice #2143
- PWA install button disappeared (#2086)
- Deleting a root folder was possible #2128
- PUT resource api errors if action against a folder (#2153)
- PUT resource api errors if action against a folder (#2153)
- LDAP authentication issue if a password caontains @ symbol (#2154)
- Share banner seems to be not working for custom urls (#2120)
@@ -774,7 +529,7 @@ Note: a potentially breaking change for docker users: \the default user is now "
- New Sidebar Features
- Sidebar tree navigation (#2006) (#350)
- Source usage to be customized to show os-reported values rather than calculated. This can be changed per source by editing the source link in the sidebar. (#1266) (#982)
- Archive/Unarchive actions in UI (#1252) (#335) (#1569)
- Archive/Unarchive actions in UI (#1252) (#335) (#1569)
- new api to archive/unarchive files on the server
- requires `create` user permissions
- archiving actions respect `server.maxArchiveSize`
@@ -937,7 +692,7 @@ Note: a potentially breaking change for docker users: \the default user is now "
## v1.2.1-beta
**New Features**:
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
**Notes**:
- upgraded imaging package and improved thumbnail generation performance. (#1797) (#1850)
@@ -1040,7 +795,7 @@ Sorry for the delay -- a lot of effort went into this release, specifically abou
- warning for low free space below 20GB
- warning if read/write latency is slow
- fatal for any errors reading or writing to the directory
- links to official docs for more info
- links to official docs for more info
- access rule changes:
- denied folders won't show up in parent directory listing view (#1684)
- tools will respect access rules
@@ -1075,7 +830,7 @@ Sorry for the delay -- a lot of effort went into this release, specifically abou
**BugFixes**:
- better index status updates, fixing delays #1649
- fixed long load times for listings with media info due sequential processing of files.
- fixed long load times for listings with media info due sequential processing of files.
- downloaded files always included `utf-8` in filename #1671
- custom sidebar links allow external links like `https://google.com`
- html title not populated correctly for links #1676
+10 -10
View File
@@ -18,7 +18,7 @@
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
:pushpin: [v2.0.0 is now stable!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) --> See [migration support discussion](https://github.com/gtsteffaniak/filebrowser/discussions/3105)
:pushpin: [v2.0.0 is now in beta!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/)
## About
@@ -86,7 +86,7 @@ See the [Official Docs](https://filebrowserquantum.com/). Contributions are welc
## Comparison Chart
Application Name | <img width="48" alt="" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" > Quantum | <img width="48" alt="" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" alt="" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" alt="" src="https://cdn.iconscout.com/icon/free/png-256/free-google-drive-logo-icon-svg-download-png-2476481.png" > Google_Drive | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
--- | --- | --- | --- | --- | --- | --- |
Filesystem support | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
@@ -95,7 +95,7 @@ Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
@@ -109,25 +109,25 @@ Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
tags support | :construction: | ❌ | ✅ | ✅ | ❌ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Metrics | :construction: | ❌ | ✅ | ✅ | ❌ | ❌ |
file space quotas | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
Metrics | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
file space quotas | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
activity log | ✅ | ❌ | ✅ | ✅ | ✅ | ✅
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:9.0.2-decode AS ffmpeg
FROM gtstef/ffmpeg:9.0-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
+2 -4
View File
@@ -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 npx playwright install --with-deps chromium-headless-shell
RUN npm i @playwright/test oauth2-mock-server @types/node
RUN npx playwright install --with-deps chromium
-1
View File
@@ -15,5 +15,4 @@ COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
ENV FILEBROWSER_ADMIN_PASSWORD=playwright-password
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
-12
View File
@@ -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'
-52
View File
@@ -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
-36
View File
@@ -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
-3
View File
@@ -5,9 +5,6 @@ http:
frontend:
<<: *frontend-main
auth:
<<: *auth-main
userDefaults:
preview:
image: true
@@ -21,7 +21,4 @@ server-main: &server-main
defaultEnabled: true
rules:
- folderPath: "/"
viewable: true
auth-main: &auth-main
adminPassword: "playwright-password"
viewable: true
@@ -1,20 +0,0 @@
server:
<<: *server-main
http:
<<: *http-main
frontend:
<<: *frontend-main
auth:
<<: *auth-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,27 +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
auth-main: &auth-main
adminPassword: "playwright-password"
@@ -1,9 +1,6 @@
http:
port: 8080
baseURL: "/files/"
auth:
adminPassword: "playwright-password"
server:
logging:
- levels: "info|error|warning|debug"
@@ -13,10 +10,3 @@ server:
name: "playwright"
config:
defaultEnabled: true
userDefaults:
listing:
viewMode: "normal"
# Size 4: switch-view "gallery" family resolves to icons (not gallery); list family → list.
gallerySize: 3
account:
showAdvancedProfile: true
@@ -12,7 +12,7 @@ import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
*/
export default defineConfig<PlaywrightFixtureOptions>({
globalSetup: "./tests/playwright/screenshots-setup.ts",
timeout: 60_000,
timeout: 5000,
testDir: "./tests/playwright/screenshots",
/* Run tests in files in parallel */
fullyParallel: false,
-1
View File
@@ -41,7 +41,6 @@ frontend:
title: "Playwright"
auth:
adminPassword: "playwright-password"
methods:
password:
signup: true
-3
View File
@@ -16,9 +16,6 @@ server:
private: true
defaultEnabled: true
auth:
adminPassword: "playwright-password"
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
-1
View File
@@ -1 +0,0 @@
FILEBROWSER_DEVMODE=true
+4 -10
View File
@@ -1,22 +1,16 @@
root = "."
tmp_dir = "tmp"
env_files = [".air.env"]
[build]
cmd = "bash ./scripts/air-build.sh"
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"
args_bin = ["-c", "test_config.yaml"]
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs", "internal/preview/gofitzinclude"]
poll = true
poll_interval = 1000
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs"]
log = "air.log"
delay = 1000 # ms
[build.windows]
cmd = "bash ./scripts/air-build.sh"
entrypoint = "./tmp/filebrowser.exe"
[log]
time = true
+22
View File
@@ -0,0 +1,22 @@
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'\" ."
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"]
exclude_dir = ["tmp", "vendor", "internal\\web\\dist", "internal\\web\\embed", "internal\\preview\\testdata", "internal\\preview\\thumbnails", "internal\\preview\\heic", "swagger\\docs"]
exclude_unchanged = true
follow_symlink = false
poll = true
poll_interval = 1000
log = "air.log"
delay = 1000 # ms
[log]
time = true
[misc]
clean_on_exit = false
-252
View File
@@ -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.5.1-0.20260828200218-ae6904d28606 // 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.4 // 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-20260922123528-4e49372c11f9 // 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-20260928145534-e18e5c5a078f // 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.15.0 // indirect
github.com/golangci/golines v0.16.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-20261005233414-e0740d52dee8 // 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.30.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.16 // 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.16 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mgechev/revive v1.17.1 // 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.15.0 // indirect
github.com/nishanths/predeclared v0.3.0 // indirect
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/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-20260923065923-98ded9ab4ef2 // indirect
github.com/timonwong/loggercheck v0.13.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.13.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.3.0 // indirect
github.com/uudashr/iface v1.5.3 // 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.42.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/tools v0.52.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.8.2 // 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
)
-788
View File
@@ -1,788 +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/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 h1:dXPJ+uOJLWb8rjlAuLX7JPiRJtZKw/53Ol+WxdDJ1Eo=
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606/go.mod h1:ZH31IJyNpxpStxP2ayCcPQz+3T2G/OnxIQqe4LWvSDw=
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/bidichk v0.3.4 h1:70G6w80G6GDZTUURHTC5gBKVjMLMw2736CzXquSf5Ek=
github.com/breml/bidichk v0.3.4/go.mod h1:GOHprnh4i0V7HrvWjtbvbpicTUcqoYt9zYESKcJkk4M=
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/ultraviolet v0.0.0-20260922123528-4e49372c11f9 h1:ZGHc3eaN2TIhkpwRibtr8N9jLQJOY1rh6GEByRoRmp4=
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9/go.mod h1:D9SVXVpAsD1pP6xR6BiERbe4+dUU6EW9US1TFrNutGA=
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/canonicalheader v0.0.0-20260928145534-e18e5c5a078f h1:pOnwRNeWLPkADfkgqKcCP7RPQAS9OIzffaBaktRsKnk=
github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f/go.mod h1:aXo+AVSX0pC3TRo2wAfR92YwBKfG6C2eal+omjKXwYw=
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/golangci-lint/v2 v2.14.0 h1:ot8QffRa4LzAAEgtvNYrVs9esxQ7xoTwoAv6uhp4ngA=
github.com/golangci/golangci-lint/v2 v2.14.0/go.mod h1:amHimCzNDP1GfVno3hIiNssjvqXmuW+CPZAHOdR4cIc=
github.com/golangci/golangci-lint/v2 v2.15.0 h1:w94zf8HAYI7hm3164kwKsvZRuqXvY+YxeFoTbu1OLg8=
github.com/golangci/golangci-lint/v2 v2.15.0/go.mod h1:/FRI304IxqDVhX2o3ZZ0qp8Vvz8egJdzCY5A4GjvmTU=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
github.com/golangci/golines v0.16.0 h1:xKdwzRvwzLd7V131B/dv90vUiEoTA0vQybdeqperPOg=
github.com/golangci/golines v0.16.0/go.mod h1:mCMgRQHiCvuALJOeaNtylu09zbzvS9Ka01dfvLLJios=
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/rowserrcheck v0.0.0-20261005233414-e0740d52dee8 h1:oN042y1yEcpldrqY81puyNA4ZjQZJzt0U/t7HyYrMic=
github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8/go.mod h1:T1Bg58Sk3N352aBbPLJPpHykFWxEEwV3o4/Enn+PPU8=
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/kisielk/errcheck v1.30.0 h1:ZIxNDAfAd4larXHTceoZP/dYPAB90dHg1OYhA2sVh5k=
github.com/kisielk/errcheck v1.30.0/go.mod h1:uiCAInmFuyOje4VDdbQSP9mgqf5gORYSXrDf19XilsI=
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/kunwardeep/paralleltest v1.0.16 h1:I0gQkLNgfigjpxWAGhx7PYj3VpswzfGrSV/715t6g+o=
github.com/kunwardeep/paralleltest v1.0.16/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-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
github.com/mattn/go-colorable v0.1.16/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/mgechev/revive v1.17.0 h1:hwTTVC3svSv9X2FA2hiYT17Uws+lfxDWhPxzBZZCREg=
github.com/mgechev/revive v1.17.0/go.mod h1:uiVNUF8ruVh4WHm/aPs7kKEeuo+m6oO3tuOfniyzP/g=
github.com/mgechev/revive v1.17.1 h1:z8hV0L3uJ6yrwOMnJbkc5qBJpxXa2AxYKFKERGJyA4s=
github.com/mgechev/revive v1.17.1/go.mod h1:FRd64pJwWSEE6j8ESoaX0PixPzjh2rpNSfckbEu3rIE=
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/exhaustive v0.15.0 h1:uFjiO/WvrwQG+ojSZ+vtl/jAFbqA2oAtfpUWK/iKQ8M=
github.com/nishanths/exhaustive v0.15.0/go.mod h1:2by9rYmg9FnsnLQC4Ixig4fQaWxRsY7RluxJuAu9sa0=
github.com/nishanths/exhaustive v0.16.0/go.mod h1:/nBwAMRn2QmCjWQ3PiJ939yRSwvWLKSa21L2C6Tp84E=
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/nishanths/predeclared v0.3.0 h1:W3i3JK/DZS5h7QwsEIyU8ler4Z2vwEwQSxNrZzhnlfk=
github.com/nishanths/predeclared v0.3.0/go.mod h1:gN4uryPExDME/zmZdqkKIPSAqcfprj316ZDI6UTqz70=
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/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 h1:HECVafB2AKntWWRfsiQ4gdunLx5WOYQzFdCsxqOrzp8=
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2/go.mod h1:JLoPVtROtNgJ99yXTIxuGNttvmJa85tkJAO6lr05qBA=
github.com/timonwong/loggercheck v0.12.0 h1:7Cb9XGLJZSWYNjgL459DIhpSiEHupD+MLQ6vaN2Pl3g=
github.com/timonwong/loggercheck v0.12.0/go.mod h1:Wa6Y+SJHQR01mViZlLLY5GAZVOWOLNMbkXddMjFWQCs=
github.com/timonwong/loggercheck v0.13.0 h1:6amIOmLLPFYt24rXUbNWzcJeNGuJc+3c31tZtO85VHs=
github.com/timonwong/loggercheck v0.13.0/go.mod h1:thn3allpox0fI5JeUWTCwiwp45peQxCZZHkHuA1Oc6I=
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/tomarrell/wrapcheck/v2 v2.13.0 h1:jYbLNs8PDkzGCY6d5LUzJdY7Bq//CTlb2HxZGV5z0JY=
github.com/tomarrell/wrapcheck/v2 v2.13.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/gocognit v1.3.0 h1:Xdw5JwAoYmX3WAB+wsXY3nxEwdw7+MWAnM0wCAAv7A4=
github.com/uudashr/gocognit v1.3.0/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/uudashr/iface v1.5.3 h1:N61bzr11L7BHP6Rct+C6zQePHjSTMeyVBq+s03uB/Ds=
github.com/uudashr/iface v1.5.3/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/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
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/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
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/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.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/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.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 v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
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=
honnef.co/go/tools v0.8.2 h1:+jPbCzuYxYrcoOvCecYUFDj+XJQRmzIhJmlbpps21Zk=
honnef.co/go/tools v0.8.2/go.mod h1:xG1Q4n+mg3bKZrO6RWFA9jFlkqplflmDRHGeuexiLDs=
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=
+20 -38
View File
@@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
@@ -224,11 +225,19 @@ func createConfig(configpath string, noInput bool) error {
fmt.Printf("Error: '%s' is not a valid port. Please enter a number between 1 and 65535.\n", portStr)
}
config.Server.Logging = []settings.LogConfig{
{
Output: "stdout",
Levels: "info|warning|error",
},
for {
levels := askQuestion(reader, "What should the log levels be?", "info|warning|error")
checkLevels := SplitByMultiple(levels)
invalidOptions := []string{}
for _, level := range checkLevels {
if !(level == "info" || level == "warning" || level == "error" || level == "debug") {
invalidOptions = append(invalidOptions, level)
}
}
if len(invalidOptions) == 0 {
break
}
fmt.Printf("Error: invalid options given '%s'. valid options: 'info|warning|error|debug'.\n", invalidOptions)
}
for {
@@ -241,20 +250,9 @@ func createConfig(configpath string, noInput bool) error {
}
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
}
const defaultFrontendName = "FileBrowser Quantum"
brandName := askQuestion(reader, "What should the application brand name be?", defaultFrontendName)
if brandName != defaultFrontendName {
config.Frontend.Name = brandName
}
adminUsername := askQuestion(reader, "What should the default admin username be?", "admin")
if adminUsername != "admin" {
config.Auth.Methods.PasswordAuth.AdminUsername = adminUsername
}
const adminPasswordGenerateDefault = "generate on startup"
adminPassword := askQuestion(reader, "What should the default admin password be?", adminPasswordGenerateDefault)
if adminPassword != "" && !strings.EqualFold(adminPassword, adminPasswordGenerateDefault) {
config.Auth.Methods.PasswordAuth.AdminPassword = adminPassword
}
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")
config.Auth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
modifyDefault := askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
config.Server.Sources[0].Config.DefaultPermissions = settings.NormalizeSourceFilePermissions(users.SourceFilePermissions{
@@ -268,12 +266,12 @@ func createConfig(configpath string, noInput bool) error {
fmt.Println("--- Configuration Complete ---")
yamlData, err := generateSetupConfigYAML(&config)
yamlData, err := yaml.Marshal(&config)
if err != nil {
return fmt.Errorf("generating config YAML: %w", err)
return fmt.Errorf("marshaling config: %w", err)
}
err = os.WriteFile(configpath, []byte(yamlData), 0600)
err = os.WriteFile(configpath, yamlData, 0600)
if err != nil {
return fmt.Errorf("writing config file: %w", err)
}
@@ -293,22 +291,6 @@ func createConfig(configpath string, noInput bool) error {
return nil
}
func generateSetupConfigYAML(config *settings.Settings) (string, error) {
embeddedPaths := []string{
"frontend/public/config.generated.yaml",
"../frontend/public/config.generated.yaml",
"internal/web/dist/config.generated.yaml",
"../internal/web/dist/config.generated.yaml",
}
for _, path := range embeddedPaths {
embeddedYaml, err := os.ReadFile(path)
if err == nil && len(embeddedYaml) > 0 {
return settings.GenerateConfigYamlWithEmbedded(config, true, false, false, string(embeddedYaml))
}
}
return settings.GenerateConfigYaml(config, true, false, false)
}
func generateYaml() {
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
if generateConfig || settings.Env.IsDevMode {
-24
View File
@@ -9,7 +9,6 @@ import (
"testing"
"github.com/alecthomas/kong"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -192,26 +191,3 @@ func TestSetupNoInputRejected(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "interactive input")
}
func TestGenerateSetupConfigYAML_MinimalWithComments(t *testing.T) {
t.Chdir("..")
cfg := settings.SetDefaults(false)
cfg.Server.Sources = []*settings.Source{{Path: "/var/lib/files"}}
cfg.Server.DatabaseV2.Path = "./filebrowser.sqlite"
cfg.Http.Port = 8080
cfg.Server.Logging = []settings.LogConfig{
{Output: "stdout", Levels: "info|warning|error"},
}
out, err := generateSetupConfigYAML(&cfg)
require.NoError(t, err)
assert.Contains(t, out, "#")
assert.Contains(t, out, "/var/lib/files")
assert.Contains(t, out, "8080")
assert.NotContains(t, out, "FileBrowser Quantum")
assert.NotContains(t, out, "darkMode")
assert.NotContains(t, out, "minSearchLength:")
assert.NotContains(t, out, "frontend:")
assert.NotContains(t, out, "rules:")
}
-12
View File
@@ -1,12 +0,0 @@
package cmd
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
+4 -63
View File
@@ -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.
@@ -289,9 +265,6 @@ func migrateUsers(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
prevVersion := user.Version
if len(user.BackendScopes) == 0 {
settings.ApplyUserDefaults(user)
if !user.Permissions.Admin {
settings.ExpandBackendScopesForCreateUserDir(user)
}
}
if prevVersion < users.SourcePermissionsMigrationVersion {
// ApplyUserDefaults bumps Version; restore pre-defaults version so legacy global file ops migrate to scopes.
@@ -299,9 +272,8 @@ func migrateUsers(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
users.MigrateToSourcePermissions(user)
}
normalizeUserTokensBeforeSQLite(user)
promoteLegacyApiKeysBeforeSQLite(user)
updateTokens(user)
user.Version = users.NewestUserVersion
user.Version = users.ProfileStorageVersion
if newScopesCount > oldScopesCount {
promoted++
logger.Infof(" user %q: legacy database had %d scopes, SQLite now has %d",
@@ -311,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 {
@@ -351,34 +320,6 @@ func normalizeUserTokensBeforeSQLite(user *users.User) {
user.Tokens = normalized
}
// promoteLegacyApiKeysBeforeSQLite merges Bolt apiKeys entries into Tokens so
// they survive the SQLite migration and the hashed-tokens backfill (strict
// auth ignores BelongsTo and requires a hash row per raw JWT).
func promoteLegacyApiKeysBeforeSQLite(user *users.User) {
if len(user.ApiKeys) == 0 {
return
}
if user.Tokens == nil {
user.Tokens = make(map[string]users.AuthToken)
}
for name, token := range user.ApiKeys {
if token.Name == "" {
token.Name = name
}
if token.Token == "" {
token.Token = token.Key
}
if token.Token == "" {
continue
}
if _, exists := user.Tokens[token.Name]; exists {
continue
}
token.Permissions = users.SanitizeTokenPermissions(token.Permissions)
users.StoreToken(user.Tokens, token)
}
}
// migrateShares migrates all shares from BoltDB to SQLite.
//
// Legacy Bolt "Link" / "Share" JSON (flat CommonShare + Link fields):
@@ -495,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, 0)
err := sqlStore.SaveHashedToken(tokenHash, uint64(userID))
if err != nil {
return fmt.Errorf("failed to save hashed token: %w", err)
}
-86
View File
@@ -2,7 +2,6 @@ package cmd
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
@@ -73,88 +72,3 @@ func TestMigrateAdminTokensRoundTripSQLite(t *testing.T) {
os.Stdout.Write(out)
os.Stdout.Write([]byte("\n"))
}
func TestPromoteLegacyApiKeysBeforeSQLite(t *testing.T) {
user := &users.User{
FrontendUser: users.FrontendUser{Username: "legacy-tokens"},
Tokens: map[string]users.AuthToken{},
}
user.ApiKeys = map[string]users.AuthToken{
"legacy-sync": {Key: "raw-jwt-1"},
"empty": {},
}
user.Tokens["obsidian"] = users.AuthToken{Name: "obsidian", Token: "raw-jwt-existing"}
promoteLegacyApiKeysBeforeSQLite(user)
// Existing Tokens entries win; empty raw tokens are skipped.
if got := user.Tokens["obsidian"].Token; got != "raw-jwt-existing" {
t.Fatalf("existing token must win, got %q", got)
}
if got := user.Tokens["legacy-sync"].Token; got != "raw-jwt-1" {
t.Fatalf("promoted token must carry raw JWT, got %q", got)
}
if _, ok := user.Tokens["raw-jwt-1"]; !ok {
t.Fatal("expected raw-JWT alias key after promotion")
}
if _, ok := user.Tokens["empty"]; ok {
t.Fatal("token without raw material must be skipped")
}
}
func TestUpdateTokenHashBackfillSuccessStampsVersion(t *testing.T) {
user := &users.User{FrontendUser: users.FrontendUser{Username: "no-tokens"}}
user.Version = users.ProfileStorageVersion
changed, failed := updateTokenHashBackfill(user)
if failed {
t.Fatal("tokenless user must not fail backfill")
}
if !changed {
t.Fatal("expected version bump to count as changed")
}
if user.Version != users.TokenHashBackfillVersion {
t.Fatalf("version = %d, want %d", user.Version, users.TokenHashBackfillVersion)
}
}
func TestUpdateTokenHashBackfillFailureSkipsBump(t *testing.T) {
// Force token hash registration to fail; the version must stay below
// newest so the backfill retries on next startup.
orig := addApiToken
addApiToken = func(string, uint64) error {
return errors.New("simulated token registration failure")
}
t.Cleanup(func() { addApiToken = orig })
user := &users.User{FrontendUser: users.FrontendUser{Username: "backfill-retry"}}
user.Version = users.ProfileStorageVersion
user.ApiKeys = map[string]users.AuthToken{
"legacy": {Key: "raw-jwt-retry"},
}
_, failed := updateTokenHashBackfill(user)
if !failed {
t.Fatal("expected failure when token registration fails")
}
if user.Version != users.ProfileStorageVersion {
t.Fatalf("failed backfill must not bump version, got %d", user.Version)
}
if bumpToNewestVersion(user, failed) {
t.Fatal("catch-all must not bump past a failed migration")
}
}
func TestBumpToNewestVersion(t *testing.T) {
user := &users.User{FrontendUser: users.FrontendUser{Username: "stale"}}
user.Version = users.ProfileStorageVersion
if !bumpToNewestVersion(user, false) {
t.Fatal("expected bump for stale version without failure")
}
if user.Version != users.NewestUserVersion {
t.Fatalf("version = %d, want %d", user.Version, users.NewestUserVersion)
}
if bumpToNewestVersion(user, false) {
t.Fatal("must not bump when already newest")
}
}
+1 -4
View File
@@ -27,9 +27,6 @@ func TestMigrateUsersFlow_appliesConfigDefaultsAndSourcePermissions(t *testing.T
prevVersion := user.Version
if len(user.BackendScopes) == 0 {
settings.ApplyUserDefaults(user)
if !user.Permissions.Admin {
settings.ExpandBackendScopesForCreateUserDir(user)
}
}
if prevVersion < users.SourcePermissionsMigrationVersion {
user.Version = prevVersion
@@ -53,7 +50,7 @@ func TestMigrateUsersFlow_appliesConfigDefaultsAndSourcePermissions(t *testing.T
t.Fatalf("scope permissions after migration: %+v", perms)
}
if user.BackendScopes[0].Scope != "/legacy-bolt-user" {
t.Fatalf("scope after applying migration defaults=%q", user.BackendScopes[0].Scope)
t.Fatalf("createUserDir scope=%q", user.BackendScopes[0].Scope)
}
}
+2 -2
View File
@@ -200,9 +200,9 @@ func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shu
if err != nil {
logger.Fatalf("Failed to create sub filesystem: %v", err)
}
fileutils.InitAssetFS(subAssets, true, false)
fileutils.InitAssetFS(subAssets, true)
} else {
fileutils.InitAssetFS(nil, false, settings.Env.IsDevMode)
fileutils.InitAssetFS(nil, false)
}
// Start preview service
+13 -58
View File
@@ -16,10 +16,6 @@ import (
var createBackup = false
// addApiToken registers a raw API token's hash with access storage. It is a
// variable so tests can inject a failing registration deterministically.
var addApiToken = state.AddApiToken
func validateUserInfo(newDB bool) {
// update source info for users if names/sources/paths might have changed
usersList, err := state.GetAllUsers()
@@ -59,22 +55,20 @@ func validateUserInfo(newDB bool) {
if normalizeApiTokenPermissions(user) {
changedFields = append(changedFields, "tokens")
}
tokenBackfillChanged, tokenBackfillFailed := updateTokenHashBackfill(user)
if tokenBackfillChanged && !tokenBackfillFailed {
changedFields = append(changedFields, "tokens", "version")
} else if tokenBackfillChanged {
changedFields = append(changedFields, "tokens")
}
if state.ApplyEnforcedSyncToUser(user) {
changedFields = append(changedFields, settings.UserJSONFieldsForEnforcedSync()...)
}
if state.ApplyEnforcedSourcePermissionsSyncToUser(user) {
changedFields = append(changedFields, "backendScopes", "backendSourcePermissions")
}
if bumpToNewestVersion(user, tokenBackfillFailed) {
if user.Version < users.ProfileStorageVersion {
user.Version = users.ProfileStorageVersion
changedFields = append(changedFields, "version")
}
adminUser := settings.PasswordAdminUsername()
adminUser := settings.Config.Auth.AdminUsername
if adminUser == "" {
adminUser = "admin"
}
if user.Username == adminUser && user.Permissions.Admin {
adminPerms := settings.AdminPerms()
if user.Permissions.Share != adminPerms.Share || user.Permissions.Api != adminPerms.Api {
@@ -84,10 +78,10 @@ func validateUserInfo(newDB bool) {
changedFields = append(changedFields, "permissions")
}
}
if user.Username == adminUser && settings.ConfigTriggersAdminPasswordReset() && user.LoginMethod == users.LoginMethodPassword {
if user.Username == adminUser && settings.Config.Auth.AdminPassword != "" && user.LoginMethod == users.LoginMethodPassword {
logger.Info("Resetting admin user to default username and password.")
user.Permissions = settings.AdminPerms()
user.Password = settings.PasswordAdminPassword()
user.Password = settings.Config.Auth.AdminPassword
user.LoginMethod = users.LoginMethodPassword
changedFields = append(changedFields, "permissions", "password", "loginMethod")
changePass = true
@@ -135,7 +129,7 @@ func dedupeFields(fields []string) []string {
}
func updateUserScopes(user *users.User) bool {
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes, user.DeclinedDefaultSources)
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes)
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
user.BackendScopes = newScopes
return changed
@@ -168,7 +162,7 @@ func updateShowFirstLogin(user *users.User) bool {
// func to convert legacy user with perm key to permissions
func updatePermissions(user *users.User) bool {
if user.Version >= users.PermMigrationVersion {
if user.Version >= 1 {
return false
}
updateUser := true
@@ -214,7 +208,7 @@ func updatePermissions(user *users.User) bool {
user.Permissions.Delete = true
updateUser = true
}
user.Version = users.PermMigrationVersion
user.Version = 2
if updateUser {
createBackup = true
}
@@ -254,7 +248,7 @@ func updateSidebarLinks(user *users.User, scopesChanged bool) bool {
}
func updateTokens(user *users.User) bool {
if user.Version >= users.TokenMigrationVersion {
if user.Version >= 2 {
return false
}
if user.ApiKeys != nil {
@@ -265,7 +259,7 @@ func updateTokens(user *users.User) bool {
users.StoreToken(user.Tokens, token)
}
}
user.Version = users.TokenMigrationVersion
user.Version = 2
return true
}
@@ -287,42 +281,3 @@ func normalizeApiTokenPermissions(user *users.User) bool {
}
return changed
}
// updateTokenHashBackfill promotes legacy ApiKeys into Tokens and registers a
// hashed_tokens row for every stored raw JWT so strict server-side auth (which
// ignores BelongsTo) accepts pre-2.0.8 tokens. Runs once per user via
// TokenHashBackfillVersion. It reports (changed, failed): when hash
// registration fails the version is left alone so callers skip the newest
// bump and the backfill retries on next startup.
func updateTokenHashBackfill(user *users.User) (bool, bool) {
if user == nil || user.Version >= users.TokenHashBackfillVersion {
return false, false
}
before := len(user.Tokens)
promoteLegacyApiKeysBeforeSQLite(user)
changed := len(user.Tokens) != before
hashesRegistered := true
for _, raw := range state.CollectStoredRawTokens(user) {
if err := addApiToken(raw, user.ID); err != nil {
logger.Errorf("could not register token hash for user %s: %v", user.Username, err)
hashesRegistered = false
}
}
if hashesRegistered {
user.Version = users.TokenHashBackfillVersion
createBackup = true
return true, false
}
return changed, true
}
// bumpToNewestVersion stamps the user at NewestUserVersion. It is skipped when
// a migration failed so the failed migration retries on next startup instead
// of being buried by the catch-all bump.
func bumpToNewestVersion(user *users.User, migrationFailed bool) bool {
if migrationFailed || user == nil || user.Version >= users.NewestUserVersion {
return false
}
user.Version = users.NewestUserVersion
return 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
+1 -1
View File
@@ -18,6 +18,7 @@ server:
userDefaults:
fileViewer:
editorQuickSave: false
disableOnlyOfficeExt: ".md .txt .pdf"
disableViewingExt: ""
sidebar:
@@ -52,7 +53,6 @@ userDefaults:
lockPassword: false
disableSettings: false
disableUpdateNotifications: false
showAdvancedProfile: false
permissions:
api: false
admin: false
+248 -34
View File
@@ -9,54 +9,126 @@ require (
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.1.0
github.com/evanoberholster/imagemeta v1.0.0
github.com/gen2brain/go-fitz v1.28.2
github.com/go-ldap/ldap/v3 v3.4.15
github.com/go-playground/validator/v10 v10.30.5
github.com/go-webauthn/webauthn v0.18.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/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.4.0
github.com/gtsteffaniak/go-ffmpeg v0.5.2
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.9.0
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.52
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.12.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.58.0
golang.org/x/image v0.47.0
golang.org/x/mod v0.42.0
golang.org/x/net v0.61.0
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.49.0
golang.org/x/term v0.47.0
golang.org/x/text v0.43.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.52.0
golang.org/x/tools v0.50.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.60.1
modernc.org/sqlite v1.59.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/Azure/go-ntlmssp v0.1.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
github.com/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/bep/helpers v0.12.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/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.1 // indirect
github.com/butuzov/mirror v1.3.3 // indirect
github.com/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/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.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.16 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.11.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/fxamacker/cbor/v2 v2.9.4 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ghostiam/protogetter v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-critic/go-critic v0.15.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
github.com/go-openapi/jsonpointer v1.0.2 // indirect
github.com/go-openapi/jsonreference v1.0.3 // 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
@@ -65,42 +137,184 @@ require (
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-playground/locales v0.14.2 // indirect
github.com/go-playground/universal-translator v0.18.2 // 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-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.166.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb // 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/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-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/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/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/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/leodido/go-urn v1.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/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/ncruces/go-strftime v1.1.0 // indirect
github.com/philhofer/fwd v1.2.1 // 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/ncruces/go-strftime v1.0.0 // 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/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.71.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/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/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/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tinylib/msgp v1.6.5 // 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/tinylib/msgp v1.6.4 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.5.1 // indirect
github.com/x448/float16 v0.8.4 // 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.etcd.io/bbolt v1.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sync v0.24.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.79.0 // indirect
honnef.co/go/tools v0.8.1 // indirect
modernc.org/libc v1.76.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.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
)
exclude (
github.com/alecthomas/go-check-sumtype v0.5.0
github.com/gobwas/glob v1.0.0
github.com/gtsteffaniak/go-ffmpeg v1.0.0
github.com/gtsteffaniak/go-ffmpeg v1.1.0
github.com/gtsteffaniak/go-ffmpeg v1.2.0
)
+683 -82
View File
@@ -1,33 +1,201 @@
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/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
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/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
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/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
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/kong v1.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E=
github.com/alecthomas/kong v1.16.1/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
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/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
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/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
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.9.0 h1:JLpAJGotKPaacb7dgv0+qBLneQjcMhJ3g9MvEhDyZUg=
github.com/bep/lazycache v0.9.0/go.mod h1:E3yeEfnmWUNNuIIR/EX6srqQwsvAFHSLQ8tR+jClATg=
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.7.0 h1:e0axmUwy+5EGQ6qk4wiOyDFuTqn3vpnoXIrxEG+yCX0=
github.com/bep/tmc v0.7.0/go.mod h1:jNqktL5Su+ScC51fw6urQRvP9Dwr1hVVGr9a3rwuGb4=
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/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
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/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
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/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
@@ -45,34 +213,59 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/evanoberholster/imagemeta v1.1.0 h1:0iBtgVxLORZTZAlKxRcNodBmjMfHCSFwUh8Asmi83oI=
github.com/evanoberholster/imagemeta v1.1.0/go.mod h1:nmf6le+qSyoRAXgp2psPbtfkxcEgf/AZGgai9pnN8d0=
github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs=
github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw=
github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E=
github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
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/evanoberholster/imagemeta v1.0.0 h1:FlSihlsjkWIoS83wmgPOP3CbAxlRpxM8jQGZgLu+MWQ=
github.com/evanoberholster/imagemeta v1.0.0/go.mod h1:2ITZJjD1ygzWIhepvn8coRvpaFcnDNk3E6hx6v7z8DU=
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/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gen2brain/go-fitz v1.28.2 h1:845G85N5TUgnq5oDqyYrW0JvehAkeo35UkkK2dJtW1M=
github.com/gen2brain/go-fitz v1.28.2/go.mod h1:pY2hqAjp9Zy7qfPI2gwbJMHBFAdZpVXOLrRxD82l3Bs=
github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA=
github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY=
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-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
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-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8=
github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ=
github.com/go-openapi/jsonreference v1.0.3/go.mod h1:OJSlMXHysgH272D9z2tiy2MGe5A9vQLHaCPb6KbYdE4=
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
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=
@@ -94,24 +287,73 @@ github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAY
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.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho=
github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak=
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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY=
github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w=
github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk=
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-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk=
github.com/go-webauthn/webauthn v0.18.2/go.mod h1:hEXaOuLxvZ3zG9miZe3ehlyeVso9AtklXG+kTn36k+A=
github.com/go-webauthn/webauthn v0.18.1 h1:KaQw6M+ODLvxHwddyeo6zFhhmicfLup/BClhigB6A+0=
github.com/go-webauthn/webauthn v0.18.1/go.mod h1:s4rZTQnKHWxIh6G3yEGqlxvtiLceA1jigll8FpqSgQ8=
github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
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.9.0 h1:f3Jb93RAP+qlmK7Ma5uGfwJslYDqTfzYJ6AeOT7nDe8=
github.com/gohugoio/httpcache v0.9.0/go.mod h1:YQsVn7oB30TG1QO9HoHc3Xq8dDQDVOdxBc0p7CYgbhE=
github.com/gohugoio/hugo v0.166.0 h1:MNGqY2FQQYofvHYMxdnvfnR/Sun+S6FDm7hd70qEG/4=
github.com/gohugoio/hugo v0.166.0/go.mod h1:jRSQi5aSXDeU2s2oJM1Z3eMRcaEuDZvWCrfjM71tFqE=
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/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -119,18 +361,44 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb h1:z//sB+BhCYNvUrk19E8aWqNx+BBom8p/uD/nh95N/5U=
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 h1:m+G0ip1+N4CF0ex34SeojAon6htIIBwvzsyXNx1fGWg=
github.com/golang/geo v0.0.0-20260818125358-b200a1149890/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -141,18 +409,43 @@ github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFe
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-ffmpeg v1.4.0 h1:XkNFlx6koCCh8cFXn9HxYEEpvLlbnxmIvWX+5HLuN+8=
github.com/gtsteffaniak/go-ffmpeg v1.4.0/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-ffmpeg v0.5.2 h1:KVnKM3zewY9yeZVd59Hb0/10gSbhYf01yRM5RftXGEU=
github.com/gtsteffaniak/go-ffmpeg v0.5.2/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
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/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -165,166 +458,466 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
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/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
github.com/kovidgoyal/imaging v1.9.0 h1:TxHvhClPANvDW7eLm7PYDTZU1KE15xbqlFv95DA6DmY=
github.com/kovidgoyal/imaging v1.9.0/go.mod h1:t2vWMc3uISm/jMP87nBCcZUcAK6EDUdgumwaXSuz0Jc=
github.com/kovidgoyal/imaging v1.8.23 h1:4WsboyQ/E8tic2kX49P93Rvscg33SPsCwTBG5EZZF54=
github.com/kovidgoyal/imaging v1.8.23/go.mod h1:k3Iot3H0v2rSWXIxQISfT3e9wIryqzAHLHCuhG7z9lM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
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/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
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/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
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/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
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/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
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/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
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/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tdewolff/minify/v2 v2.24.17 h1:6AbitfVyq0M7aW6i+XL7+49DeTQZwloOMs9O574arBg=
github.com/tdewolff/minify/v2 v2.24.17/go.mod h1:kVqn9vxXUKtlHexSNrWbYePqioOT5mc4ou/KVSMpfCM=
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/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
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/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
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.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/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.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
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/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
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.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
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.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
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-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/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-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
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.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
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/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
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.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
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-20200202164722-d101bd2416d5/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-20210320140829-1e4c9ba3b0c4/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.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
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/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
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/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
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.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
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/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
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-20190902080502-41f04d3bba15/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.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w=
honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.2 h1:JPAIttQRHdY7aRdr04+iTW7Sx+6OSZcmKJ0OZl/tNaA=
modernc.org/ccgo/v4 v4.35.2/go.mod h1:9sddcpn4NuDAFGtBPa2Dk3NHfnQfcoKveCC5crwWp8I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/libc v1.76.0 h1:eaJHMv2zn5oXT6IPXPwxAMVpzmQzSDsCdKcNl1ZpaRg=
modernc.org/libc v1.76.0/go.mod h1:2h0dedmVSE8qH2DrxzYDXbQaxLMl0XNg8Z7/HJRdk2M=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
@@ -333,9 +926,17 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
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=
+1 -1
View File
@@ -53,7 +53,7 @@ func TestUserUpdateChangesFiltersUnchangedFields(t *testing.T) {
which := []string{
"preview", "stickySidebar", "darkMode", "locale", "singleClick", "sorting",
"showHidden", "dateFormat", "themeColor", "quickDownload", "fileLoading",
"sidebarLinks", "deleteAfterArchive", "preferEditorForMarkdown", "showFirstLogin", "requirePasswordChange",
"sidebarLinks", "deleteAfterArchive", "preferEditorForMarkdown", "showFirstLogin",
}
changes := UserUpdateChanges(before, &after, which, false)
+3 -101
View File
@@ -7,7 +7,6 @@ import (
"time"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
@@ -163,18 +162,8 @@ func RecordPatchItem(r *http.Request, actor *Actor, action string, item MoveCopy
RecordUser(r, actor, entry)
}
// recordFileEvent records a file operation. Anonymous share visitors have no user ID,
// so their uploads and deletes are attributed to the share owner.
func recordFileEvent(r *http.Request, actor *Actor, entry activitydb.Entry) {
if actor != nil && actor.Share.Hash != "" && (actor.User == nil || actor.User.ID == 0) {
RecordShareOwner(r, actor, entry)
return
}
RecordUser(r, actor, entry)
}
func RecordDelete(r *http.Request, actor *Actor, source, path string) {
recordFileEvent(r, actor, activitydb.Entry{
RecordUser(r, actor, activitydb.Entry{
EventType: activitydb.EventDelete,
Source: source,
Path: path,
@@ -208,7 +197,7 @@ func RecordBulkDelete(r *http.Request, actor *Actor, succeeded []BulkDeleteItem)
entry.Details.Source = succeeded[0].Source
entry.Details.Path = succeeded[0].Path
}
recordFileEvent(r, actor, entry)
RecordUser(r, actor, entry)
}
func RecordUpload(r *http.Request, actor *Actor, source, path string, isDir bool) {
@@ -220,7 +209,7 @@ func RecordUpload(r *http.Request, actor *Actor, source, path string, isDir bool
Source: source,
Path: pathLabel,
}
recordFileEvent(r, actor, activitydb.Entry{
RecordUser(r, actor, activitydb.Entry{
EventType: activitydb.EventUpload,
Source: source,
Path: pathLabel,
@@ -228,20 +217,6 @@ func RecordUpload(r *http.Request, actor *Actor, source, path string, isDir bool
})
}
// RecordFileModify appends an audit row for an in-place file content update (PUT).
func RecordFileModify(r *http.Request, actor *Actor, source, path string) {
entry := activitydb.Entry{
EventType: activitydb.EventFileModify,
Source: source,
Path: path,
Details: activitydb.Details{
Source: source,
Path: path,
},
}
recordFileEvent(r, actor, entry)
}
func RecordShareMutation(r *http.Request, actor *Actor, eventType activitydb.EventType, hash, sourceName, path string, changes []activitydb.FieldChange) {
details := activitydb.Details{
Source: sourceName,
@@ -312,79 +287,6 @@ func RecordAccessDelete(r *http.Request, actor *Actor, source, path string, chan
})
}
func RecordQuotaCreate(r *http.Request, actor *Actor, source, path string, changes []activitydb.FieldChange) {
RecordQuotaMutation(r, actor, activitydb.EventQuotaCreate, source, path, changes)
}
func RecordQuotaUpdate(r *http.Request, actor *Actor, source, path string, changes []activitydb.FieldChange) {
RecordQuotaMutation(r, actor, activitydb.EventQuotaUpdate, source, path, changes)
}
func RecordQuotaDelete(r *http.Request, actor *Actor, source, path string, changes []activitydb.FieldChange) {
RecordQuotaMutation(r, actor, activitydb.EventQuotaDelete, source, path, changes)
}
func RecordQuotaMutation(r *http.Request, actor *Actor, eventType activitydb.EventType, source, path string, changes []activitydb.FieldChange) {
RecordUser(r, actor, activitydb.Entry{
EventType: eventType,
Source: source,
Path: path,
Details: activitydb.Details{
Source: source,
Path: path,
Changes: changes,
},
})
}
func quotaFolderChanges(q quota.FolderQuota) []activitydb.FieldChange {
changes := []activitydb.FieldChange{
{Field: "limitBytes", To: strconv.FormatInt(q.LimitBytes, 10)},
{Field: "meter", To: q.Meter},
}
if q.UserID > 0 {
changes = append(changes, activitydb.FieldChange{
Field: "userId",
To: strconv.FormatUint(q.UserID, 10),
})
}
return changes
}
func QuotaFolderCreateChanges(q quota.FolderQuota) []activitydb.FieldChange {
return quotaFolderChanges(q)
}
func QuotaFolderDeleteChanges(q quota.FolderQuota) []activitydb.FieldChange {
return quotaFolderChanges(q)
}
func QuotaFolderUpdateChanges(before, after quota.FolderQuota) []activitydb.FieldChange {
var changes []activitydb.FieldChange
if before.LimitBytes != after.LimitBytes {
changes = append(changes, activitydb.FieldChange{
Field: "limitBytes",
From: strconv.FormatInt(before.LimitBytes, 10),
To: strconv.FormatInt(after.LimitBytes, 10),
})
}
if before.Meter != after.Meter {
changes = append(changes, activitydb.FieldChange{
Field: "meter",
From: before.Meter,
To: after.Meter,
})
}
if before.UserID != after.UserID {
changes = append(changes, activitydb.FieldChange{
Field: "userId",
From: strconv.FormatUint(before.UserID, 10),
To: strconv.FormatUint(after.UserID, 10),
})
}
return changes
}
func AccessRuleCreateChanges(allow bool, ruleCategory, value string) []activitydb.FieldChange {
ruleType := "deny"
if allow {
-84
View File
@@ -1,13 +1,9 @@
package activity
import (
"net/http/httptest"
"testing"
"time"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestAccessRuleCreateChanges(t *testing.T) {
@@ -32,86 +28,6 @@ func TestAccessRuleDeleteChanges(t *testing.T) {
assertFieldChange(t, changes[4], "count", "", "3")
}
func TestShareFileEventsRecordedForShareOwner(t *testing.T) {
store := &mockActivityStore{}
globalMu.Lock()
old := globalRecorder
globalMu.Unlock()
defer func() {
globalMu.Lock()
globalRecorder = old
globalMu.Unlock()
}()
rec := &Recorder{
store: store,
buffer: make([]activitydb.Entry, 0, 4),
flushCh: make(chan struct{}, 1),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
maxBuffer: 10000,
flushInterval: time.Hour,
enabled: true,
}
globalMu.Lock()
globalRecorder = rec
globalMu.Unlock()
go rec.loop()
actor := &Actor{
User: &users.User{FrontendUser: users.FrontendUser{Username: "anonymous"}},
Share: share.Share{Hash: "share-hash", UserID: 7},
}
r := httptest.NewRequest("POST", "/public/api/resources", nil)
RecordUpload(r, actor, "main", "/pub/new.txt", false)
RecordDelete(r, actor, "main", "/pub/old.txt")
RecordBulkDelete(r, actor, []BulkDeleteItem{{Source: "main", Path: "/pub/a.txt"}})
loggedIn := &Actor{
User: &users.User{ID: 5, FrontendUser: users.FrontendUser{Username: "bob"}},
Share: share.Share{Hash: "share-hash", UserID: 7},
}
RecordUpload(r, loggedIn, "main", "/pub/bob.txt", false)
RecordFileModify(r, loggedIn, "main", "/pub/edited.txt")
RecordFileModify(r, actor, "main", "/pub/anon-edit.txt")
rec.Stop()
var got []activitydb.Entry
for _, batch := range store.inserts {
got = append(got, batch...)
}
want := []activitydb.EventType{
activitydb.EventUpload,
activitydb.EventDelete,
activitydb.EventBulkDelete,
activitydb.EventUpload,
activitydb.EventFileModify,
activitydb.EventFileModify,
}
if len(got) != len(want) {
t.Fatalf("expected %d entries, got %d: %+v", len(want), len(got), got)
}
for i, e := range got {
if e.EventType != want[i] {
t.Fatalf("entry %d: event type %q, want %q", i, e.EventType, want[i])
}
}
for i := 0; i < 3; i++ {
e := got[i]
if e.UserID != 7 || e.Details.ShareHash != "share-hash" {
t.Fatalf("anonymous share entry %d: userID=%d shareHash=%q", i, e.UserID, e.Details.ShareHash)
}
}
if got[3].UserID != 5 {
t.Fatalf("logged-in share visitor upload: userID=%d, want 5", got[3].UserID)
}
if got[4].UserID != 5 {
t.Fatalf("logged-in share visitor modify: userID=%d, want 5", got[4].UserID)
}
if got[5].UserID != 7 || got[5].Details.ShareHash != "share-hash" {
t.Fatalf("anonymous share modify: userID=%d shareHash=%q", got[5].UserID, got[5].Details.ShareHash)
}
}
func assertFieldChange(t *testing.T, change activitydb.FieldChange, field, from, to string) {
t.Helper()
if change.Field != field || change.From != from || change.To != to {
+5 -9
View File
@@ -3,6 +3,7 @@ package activity
import (
"net"
"net/http"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
@@ -10,19 +11,14 @@ import (
func remoteIP(r *http.Request) string {
cfg := &settings.Config
// Forwarded client headers are only honored when the socket peer is a
// configured trusted proxy; otherwise a direct client could spoof its IP.
trustedPeer := cfg.Http.TrustProxyHeaders && settings.IsTrustedProxyPeer(r)
xff := r.Header.Get("X-Forwarded-For")
if trustedPeer && xff != "" {
if ip := settings.TrustedProxyClientIP(xff); ip != "" {
return ip
}
if cfg.Http.TrustProxyHeaders && xff != "" {
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
}
xri := r.Header.Get("X-Real-IP")
if trustedPeer && xri != "" {
if cfg.Http.TrustProxyHeaders && xri != "" {
return xri
}
+9 -126
View File
@@ -12,11 +12,11 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func Test_GetRealPath(t *testing.T) {
@@ -129,7 +129,7 @@ func TestWriteFilePreservesExecutableBit(t *testing.T) {
waitForScannerReady(t, idx)
indexPath := "/" + scriptName
if err := WriteFile(sourceName, "", indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
if err := WriteFile(sourceName, indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
t.Fatalf("WriteFile: %v", err)
}
@@ -182,7 +182,7 @@ func TestWriteFileRejectsExistingDirectory(t *testing.T) {
defer stopPoll()
waitForScannerReady(t, idx)
err := WriteFile(sourceName, "", "/"+dirName, strings.NewReader("file content"))
err := WriteFile(sourceName, "/"+dirName, strings.NewReader("file content"))
if err == nil {
t.Fatal("expected error when path is an existing directory")
}
@@ -571,7 +571,7 @@ func TestDeleteFilesRootProtection(t *testing.T) {
}
// Test: Attempting to delete the root directory should return an error
err := DeleteFiles("test_root_protection", "", "/", true)
err := DeleteFiles("test_root_protection", realTmpDir, true)
if err == nil {
t.Error("DeleteFiles should return an error when trying to delete root directory")
}
@@ -583,13 +583,13 @@ func TestDeleteFilesRootProtection(t *testing.T) {
}
// Test with trailing slash - should still be blocked
err = DeleteFiles("test_root_protection", "", "/", true)
err = DeleteFiles("test_root_protection", realTmpDir+"/", true)
if err == nil {
t.Error("DeleteFiles should return an error for root directory with trailing slash")
}
// Test with cleaned path variations
err = DeleteFiles("test_root_protection", "", "/", true)
err = DeleteFiles("test_root_protection", realTmpDir+"/.", true)
if err == nil {
t.Error("DeleteFiles should return an error for root directory with /. suffix")
}
@@ -670,7 +670,7 @@ func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
waitForScannerReady(t, idx)
err := DeleteFiles(sourceName, "", "/"+subfolderName, true)
err := DeleteFiles(sourceName, subfolderPath, true)
stopPolling()
t.Logf("iteration %d: DeleteFiles finished in %s (err=%v)", iter, time.Since(start), err)
@@ -694,130 +694,13 @@ func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
t.Logf("iteration %d: cleaned scanners and indices", iter)
}
// TestDeleteFiles_RemovesSymlinkNotTarget verifies that deleting a symlink leaf
// removes the link itself and never follows it to the target.
func TestDeleteFiles_RemovesSymlinkNotTarget(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(filepath.Dir(root), "outside-victim.txt")
if err := os.WriteFile(outside, []byte("keep me"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
t.Fatal(err)
}
sourceName := "test_delete_symlink"
indexing.SetTestIndex(sourceName, root)
t.Cleanup(indexing.ClearTestIndices)
idx := indexing.GetIndex(sourceName)
idx.Config.ResolvedRules.IndexingDisabled = true
if err := DeleteFiles(sourceName, "", "/link", false); err != nil {
t.Fatalf("DeleteFiles: %v", err)
}
if _, err := os.Lstat(filepath.Join(root, "link")); !os.IsNotExist(err) {
t.Fatal("symlink should have been removed")
}
got, err := os.ReadFile(outside)
if err != nil || string(got) != "keep me" {
t.Fatalf("delete followed the symlink and removed the target: %v %q", err, got)
}
}
// TestDeleteFiles_RejectsEscapingIntermediate verifies a delete under an
// intermediate symlink escaping the source is refused.
func TestDeleteFiles_RejectsEscapingIntermediate(t *testing.T) {
root := t.TempDir()
outsideDir := filepath.Join(filepath.Dir(root), "outside-dir")
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideDir, filepath.Join(root, "evil")); err != nil {
t.Fatal(err)
}
victim := filepath.Join(outsideDir, "victim.txt")
if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
sourceName := "test_delete_escape"
indexing.SetTestIndex(sourceName, root)
t.Cleanup(indexing.ClearTestIndices)
idx := indexing.GetIndex(sourceName)
idx.Config.ResolvedRules.IndexingDisabled = true
err := DeleteFiles(sourceName, "", "/evil/victim.txt", false)
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
if _, err := os.Stat(victim); err != nil {
t.Fatal("delete escaped the bound and removed the outside file")
}
}
// TestWriteFile_RejectsSymlinkEscape verifies a write through a symlink leaf
// resolving outside the source is refused before any bytes are written.
func TestWriteFile_RejectsSymlinkEscape(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(filepath.Dir(root), "outside.txt")
if err := os.WriteFile(outside, []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "evil")); err != nil {
t.Fatal(err)
}
sourceName := "test_write_symlink"
indexing.SetTestIndex(sourceName, root)
t.Cleanup(indexing.ClearTestIndices)
idx := indexing.GetIndex(sourceName)
idx.Config.ResolvedRules.IndexingDisabled = true
err := WriteFile(sourceName, "", "/evil", strings.NewReader("pwned"))
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
got, _ := os.ReadFile(outside)
if string(got) != "original" {
t.Fatal("write went through the symlink")
}
}
// TestWriteFile_RejectsMissingLeafUnderEscapingParent verifies the j764/3j46
// primitive: writing a new file under a symlinked directory that escapes the
// source is refused even though the leaf does not exist yet.
func TestWriteFile_RejectsMissingLeafUnderEscapingParent(t *testing.T) {
root := t.TempDir()
outsideDir := filepath.Join(filepath.Dir(root), "outside-parent")
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideDir, filepath.Join(root, "evil_dir")); err != nil {
t.Fatal(err)
}
sourceName := "test_write_escape_parent"
indexing.SetTestIndex(sourceName, root)
t.Cleanup(indexing.ClearTestIndices)
idx := indexing.GetIndex(sourceName)
idx.Config.ResolvedRules.IndexingDisabled = true
err := WriteFile(sourceName, "", "/evil_dir/hack.txt", strings.NewReader("pwned"))
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
if _, err := os.Lstat(filepath.Join(outsideDir, "hack.txt")); !os.IsNotExist(err) {
t.Fatal("write landed outside the source through the symlinked parent")
}
}
func waitForScannerReady(t *testing.T, idx *indexing.Index) {
t.Helper()
// Give the scanners a moment to initialize, but don't wait for full "ready" status
// The initial scan can take longer than the test timeout, but DeleteFiles will work
// as long as the scanners are initialized and running
time.Sleep(500 * time.Millisecond)
// Verify scanners exist
status := idx.GetScannerStatus()
if totalScanners, ok := status["totalScanners"].(int); !ok || totalScanners == 0 {
+144 -162
View File
@@ -1,11 +1,13 @@
package files
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@@ -23,6 +25,8 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
var reDuration = regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
var CheckPermissionsFunc = func(opts utils.FileOptions, user *users.User) (string, string, error) {
return checkPermissionsImpl(opts, user, svc())
@@ -46,7 +50,7 @@ func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileO
wg.Go(func() {
fullPath := response.RealPath + "/" + fileItem.Name
if isAudio {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, sharedFFmpegService); err != nil {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, false, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
}
} else {
@@ -71,18 +75,13 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
}
var wg sync.WaitGroup
// Expanded listings already persisted index rows and the folder size from
// the freshly read directory (persistDirectoryListing); only unexpanded
// directory requests still need a refresh walk.
if !opts.Expand {
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true).String()
err := idx.RefreshDirectory(indexPath, false)
if err != nil {
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
}
})
}
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true).String()
err := idx.RefreshDirectory(indexPath, false)
if err != nil {
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
}
})
if opts.Metadata && metadataCount > 0 {
addMetadataToChildren(response, opts, &wg)
@@ -335,15 +334,6 @@ func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (*
if info.Type == "directory" && opts.HideFileExt != "" {
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
}
if info.Type == "directory" && opts.Expand {
// Expanded listings without extended attrs skip persistDirectoryListing
// (their placeholder preview values must not overwrite index rows), so
// refresh the index lazily — otherwise folder sizes here never update.
// Capture indexPath now: the deferred finalizeResponse strips the user
// scope from response.Path before returning.
// Coalesced per path so repeated listings do not run parallel refreshes.
idx.RefreshDirectoryAsync(idx.MakeIndexPath(response.Path, true).String())
}
return response, nil
}
// Process directory metadata if requested
@@ -403,7 +393,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, false, nil)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
@@ -441,10 +431,57 @@ func generateOfficeId(realPath string) string {
return key
}
// Parses raw LRC text into a slice of Lyric.
// Lines with [hh:mm:ss.xx] (timestamps) will be parsed to ms for synced lyrics
// The ones without timestamp are kept as unsynced (timestamp 0).
func parseLRC(raw string) []iteminfo.Lyric {
var lines []iteminfo.Lyric
scanner := bufio.NewScanner(strings.NewReader(raw))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
matches := reDuration.FindStringSubmatch(line)
if len(matches) == 6 {
// Hours default to 0 (if not present in the timestamps)
h := 0
if matches[1] != "" {
h, _ = strconv.Atoi(matches[1])
}
min, _ := strconv.Atoi(matches[2])
sec, _ := strconv.Atoi(matches[3])
msStr := matches[4]
// Normalise to 3 digits, some songs have more than 3 digits in the milliseconds
switch {
case len(msStr) < 3:
msStr += strings.Repeat("0", 3-len(msStr))
case len(msStr) > 3:
msStr = msStr[:3]
}
ms, _ := strconv.Atoi(msStr)
timestamp := int64((h*3600+min*60+sec)*1000 + ms)
text := strings.TrimSpace(matches[5])
lines = append(lines, iteminfo.Lyric{
Text: text,
Timestamp: timestamp,
})
} else {
// Line without a valid timestamp – treat as unsynced (timestamp 0)
lines = append(lines, iteminfo.Lyric{
Text: line,
Timestamp: 0,
})
}
}
return lines
}
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
// and optionally extracts duration using the ffmpeg service with concurrency control
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, ffmpegService *ffmpeg.FFmpegService) error {
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, getLyrics bool, ffmpegService *ffmpeg.FFmpegService) error {
file, err := os.Open(realPath)
if err != nil {
return err
@@ -481,7 +518,28 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
item.Metadata.Track = track
// Check if lyrics are available without extract them
item.Metadata.HasLyrics = hasLyricsSidecar(realPath) || m.Lyrics() != ""
if rawLyrics := m.Lyrics(); rawLyrics != "" {
item.Metadata.HasLyrics = true
} else {
// Check for sidecar .lrc file
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
if _, err := os.Stat(lrcPath); err == nil {
item.Metadata.HasLyrics = true
}
}
if getLyrics {
lyrics, err := ExtractLyrics(realPath)
if err != nil {
logger.Debugf("failed to extract lyrics for %s: %v", realPath, err)
} else if len(lyrics) > 0 {
item.Metadata.Lyrics = lyrics
}
}
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
// This respects concurrency limits and gracefully handles missing ffmpeg
@@ -514,91 +572,35 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
var lyricsSidecarExts = []string{".elrc", ".lrc", ".srt", ".vtt"}
func lyricsSidecar(dir, nameWithoutExt, ext string) (path string, ok bool) {
sidecarPath := filepath.Join(dir, nameWithoutExt+ext)
info, err := os.Lstat(sidecarPath)
if err != nil {
return "", false
}
if !info.Mode().IsRegular() {
return "", false
}
if info.Size() > 15*1024*1024 { // 15MB
return "", false
}
return sidecarPath, true
}
func findLyricsSidecar(realPath string) (data []byte, ext string, ok bool) {
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
nameWithoutExt := strings.TrimSuffix(base, filepath.Ext(base))
for _, ext = range lyricsSidecarExts {
sidecarPath, candOk := lyricsSidecar(dir, nameWithoutExt, ext)
if !candOk {
continue
}
data, err := os.ReadFile(sidecarPath)
if err != nil {
continue
}
if strings.TrimSpace(string(data)) == "" {
continue
}
return data, strings.TrimPrefix(ext, "."), true
}
return nil, "", false
}
func hasLyricsSidecar(realPath string) bool {
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
nameWithoutExt := strings.TrimSuffix(base, filepath.Ext(base))
for _, ext := range lyricsSidecarExts {
sidecarPath, ok := lyricsSidecar(dir, nameWithoutExt, ext)
if !ok {
continue
}
f, err := os.Open(sidecarPath)
if err != nil {
continue
}
buf := make([]byte, 4096)
for {
n, readErr := f.Read(buf)
if n > 0 && strings.TrimSpace(string(buf[:n])) != "" {
f.Close()
return true
}
if readErr != nil {
break
}
}
f.Close()
}
return false
}
// Returns raw lyrics from an audio file (from embedded tags or from sidecar files with the same name).
func ExtractLyrics(realPath string) (string, string, error) {
if data, ext, ok := findLyricsSidecar(realPath); ok {
return string(data), ext, nil
}
// Returns lyrics from an audio file (from embedded tags or from a .lrc file with the same name).
func ExtractLyrics(realPath string) ([]iteminfo.Lyric, error) {
file, err := os.Open(realPath)
if err != nil {
return "", "", err
return nil, err
}
defer file.Close()
m, err := tag.ReadFrom(file)
if err != nil {
return "", "", err
return nil, err
}
var lyrics []iteminfo.Lyric
if raw := m.Lyrics(); raw != "" {
return raw, "embedded", nil
lyrics = parseLRC(raw)
}
return "", "", nil
if len(lyrics) == 0 {
// Check for sidecar .lrc file
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
if data, err := os.ReadFile(lrcPath); err == nil {
lyrics = parseLRC(string(data))
}
}
return lyrics, nil
}
// extractVideoMetadata extracts duration and codec info from video files using ffprobe.
@@ -643,53 +645,47 @@ func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
// DeleteFiles deletes the file or directory at indexPath. boundIndexPath limits
// symlink resolution to the caller's scope ("" or "/" = source root only). The
// entry itself is removed: a symlink leaf is deleted, never followed, and an
// intermediate symlinked component cannot carry the delete outside the bound.
func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
func DeleteFiles(source, absPath string, isDir bool) error {
index := indexing.GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
if utils.CleanIndexPathSegment(indexPath) == "" {
return fmt.Errorf("refusing to delete source root directory: %s", indexPath)
// Safety guard: never allow deletion of the source root directory itself
cleanAbs := filepath.Clean(absPath)
if cleanAbs == filepath.Clean(index.Path) {
return fmt.Errorf("refusing to delete source root directory: %s", absPath)
}
realPath, err := index.GetRealPathForRemoval(boundIndexPath, indexPath)
if err != nil {
if !index.Config.ResolvedRules.IndexingDisabled {
indexPath := index.MakeIndexPath(absPath, isDir).String()
// Perform the physical deletion
err := os.RemoveAll(absPath)
if err != nil {
return err
}
// Clear cache entries
indexing.RealPathCache.Delete(absPath)
indexing.IsDirCache.Delete(absPath + ":isdir")
// Remove metadata from index
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
if !deleteSuccess {
logger.Errorf("Failed to delete metadata from index for %s, but filesystem deletion succeeded", indexPath)
}
// Refresh the parent directory to recalculate sizes and update counts
refreshConfig := utils.FileOptions{
Path: index.MakeIndexPath(filepath.Dir(absPath), true).String(),
IsDir: true,
}
err = index.RefreshFileInfo(refreshConfig)
return err
}
if index.Config.ResolvedRules.IndexingDisabled {
// Indexing disabled, just delete the file
return os.RemoveAll(realPath)
}
indexPath = index.MakeIndexPath(indexPath, isDir).String()
// Perform the physical deletion
err = os.RemoveAll(realPath)
if err != nil {
return err
}
// Clear cache entries
indexing.RealPathCache.Delete(realPath)
indexing.IsDirCache.Delete(realPath + ":isdir")
// Remove metadata from index
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
if !deleteSuccess {
logger.Errorf("Failed to delete metadata from index for %s, but filesystem deletion succeeded", indexPath)
}
// Refresh the parent directory to recalculate sizes and update counts
refreshConfig := utils.FileOptions{
Path: index.MakeIndexPath(filepath.Dir(strings.TrimSuffix(indexPath, "/")), true).String(),
IsDir: true,
}
err = index.RefreshFileInfo(refreshConfig)
return err
// Indexing disabled, just delete the file
return os.RemoveAll(absPath)
}
func RefreshIndex(source string, path string, isDir bool, recursive bool) error {
@@ -916,19 +912,15 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
return nil
}
// WriteDirectory creates a directory at opts.Path. boundIndexPath limits symlink
// resolution to the caller's scope ("" or "/" = source root only).
func WriteDirectory(boundIndexPath string, opts utils.FileOptions) error {
func WriteDirectory(opts utils.FileOptions) error {
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, err := idx.GetRealPathForMutation(boundIndexPath, opts.Path)
if err != nil {
return err
}
realPath, _, _ := idx.GetRealPath(opts.Path)
var stat os.FileInfo
var err error
// Check if the destination exists and is a file
if stat, err = os.Stat(realPath); err == nil && !stat.IsDir() {
// If it's a file and we're trying to create a directory, remove the file first
@@ -969,27 +961,17 @@ func WriteDirectory(boundIndexPath string, opts utils.FileOptions) error {
return nil
}
// WriteFileFunc is a variable that can be mocked in tests
var WriteFileFunc = writeFile
// WriteFile writes stream contents to the file at index path path. boundIndexPath
// limits symlink resolution to the caller's scope ("" or "/" = source root only).
func WriteFile(source, boundIndexPath, path string, in io.Reader) error {
return WriteFileFunc(source, boundIndexPath, path, in)
}
func writeFile(source, boundIndexPath, path string, in io.Reader) error {
func WriteFile(source, path string, in io.Reader) error {
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", source)
}
realPath, err := idx.GetRealPathForMutation(boundIndexPath, path)
if err != nil {
return err
}
realPath, _, _ := idx.GetRealPath(path)
// Strip trailing slash from realPath if it's meant to be a file
realPath = strings.TrimRight(realPath, "/")
// Ensure the parent directories exist
parentDir := filepath.Dir(realPath)
err = os.MkdirAll(parentDir, fileutils.PermDir)
err := os.MkdirAll(parentDir, fileutils.PermDir)
if err != nil {
return err
}
@@ -447,7 +447,6 @@ var types = map[string]string{
".vtt": "text/subtitle-vtt",
".ssa": "text/subtitle-ssa",
".lrc": "text/lyrics",
".elrc": "text/lyrics",
".sbv": "text/plain",
".ssi": "text/x-server-parsed-html",
".ssm": "application/streamingmedia",
+2 -4
View File
@@ -5,8 +5,8 @@ import (
"os"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -19,13 +19,11 @@ func MakeUserDir(fullPath string) error {
}
func MakeUserDirs(u *users.User, createDir bool) error {
if u.Permissions.Admin {
return nil
}
cleanedUserName := users.CleanUsername(u.Username)
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
}
settings.ExpandBackendScopesForCreateUserDir(u)
for i, scope := range u.BackendScopes {
source, ok := settings.Config.Server.SourceMap[scope.Path]
if !ok {
@@ -1,54 +0,0 @@
package files
import (
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestMakeUserDirsSkipsAdmins(t *testing.T) {
root := t.TempDir()
saved := settings.Config
t.Cleanup(func() { settings.Config = saved })
settings.Config = settings.Settings{
Server: settings.Server{
Sources: []*settings.Source{{
Path: root,
Config: settings.SourceConfig{
CreateUserDir: true,
},
}},
SourceMap: map[string]*settings.Source{
root: {
Path: root,
Config: settings.SourceConfig{
CreateUserDir: true,
},
},
},
},
}
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
Permissions: users.Permissions{
Admin: true,
},
},
BackendScopes: []users.BackendScope{{Path: root, Scope: "/"}},
}
if err := MakeUserDirs(u, true); err != nil {
t.Fatalf("MakeUserDirs: %v", err)
}
if got := u.BackendScopes[0].Scope; got != "/" {
t.Fatalf("scope=%q want /", got)
}
if _, err := os.Stat(filepath.Join(root, "admin")); !os.IsNotExist(err) {
t.Fatalf("admin directory stat error=%v, want not-exist", err)
}
}
@@ -21,13 +21,11 @@ func (d dirFS) Open(name string) (fs.File, error) {
// InitAssetFS initializes the asset filesystem for the application
// This should be called once during startup before http or preview services start
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool, devMode bool) {
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
if useEmbedded {
assetFs = embeddedAssets
} else if devMode {
// Dev mode: serve frontend source assets (templates, fonts, icons)
assetFs = dirFS{Dir: http.Dir("../frontend/public")}
} else {
// Dev mode: Serve files from internal/web/dist directory
assetFs = dirFS{Dir: http.Dir("internal/web/dist")}
}
}
+25 -73
View File
@@ -55,79 +55,49 @@ func unixModeToFileMode(u uint32) os.FileMode {
return m
}
// removeDestinationSymlinkIfPresent deletes dest when it is a symlink so a copy or
// cross-device move replaces the link entry instead of writing through it.
func removeDestinationSymlinkIfPresent(dest string) error {
info, err := os.Lstat(dest)
// MoveFile moves a file from src to dst.
// By default, the rename system call is used. If src and dst point to different volumes,
// the file copy is used as a fallback.
func MoveFile(src, dst string) error {
err := os.Rename(src, dst)
if err == nil {
return nil
}
// fallback
err = CopyFile(src, dst)
if err != nil {
if os.IsNotExist(err) {
return nil
}
logger.Errorf("CopyFile failed %v", err)
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return os.Remove(dest)
}
return nil
}
// MoveFile moves a file from src to dst.
// By default, the rename system call is used. If src and dst are on different volumes,
// CopyFile is used as a fallback and the source is removed synchronously on success.
func MoveFile(src, dst string) error {
if err := os.Rename(src, dst); err != nil {
if !isCrossDeviceRenameError(err) {
return err
go func() {
err = os.RemoveAll(src)
if err != nil {
logger.Errorf("os.Remove failed %v", err)
}
if err := CopyFile(src, dst); err != nil {
return err
}
return os.RemoveAll(src)
}
return nil
}
}()
func preserveFileMode(path string, mode os.FileMode) error {
return os.Chmod(path, mode.Perm())
return nil
}
// CopyFile copies a file or directory from source to dest and returns an error if any.
func CopyFile(source, dest string) error {
info, err := os.Lstat(source)
// Check if the source exists and whether it's a file or directory.
info, err := os.Stat(source)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return copySymlink(source, dest)
}
if info.IsDir() {
// If the source is a directory, copy it recursively.
return copyDirectory(source, dest)
}
// If the source is a file, copy the file.
return copySingleFile(source, dest)
}
func copySymlink(source, dest string) error {
target, err := os.Readlink(source)
if err != nil {
return err
}
destDir := filepath.Dir(dest)
if err = os.MkdirAll(destDir, EffectiveDirPerm()); err != nil {
return err
}
tmpDir, err := os.MkdirTemp(destDir, ".fb-link-*")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmpPath := filepath.Join(tmpDir, "link")
if err := os.Symlink(target, tmpPath); err != nil {
return err
}
return os.Rename(tmpPath, dest)
}
// copySingleFile handles copying a single file.
func copySingleFile(source, dest string) error {
// Get source file info to preserve permissions
@@ -150,10 +120,6 @@ func copySingleFile(source, dest string) error {
return err
}
if err = removeDestinationSymlinkIfPresent(dest); err != nil {
return err
}
// Create the destination file with source permissions
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, sourcePerms)
if err != nil {
@@ -195,7 +161,7 @@ func copyFileContents(dst io.Writer, src io.Reader) error {
// copyDirectory handles copying directories recursively.
func copyDirectory(source, dest string) error {
srcInfo, err := os.Lstat(source)
srcInfo, err := os.Stat(source)
if err != nil {
return err
}
@@ -204,9 +170,6 @@ func copyDirectory(source, dest string) error {
if err != nil {
return err
}
if err = preserveFileMode(dest, srcInfo.Mode()); err != nil {
logger.Debugf("Could not set directory permissions for %s: %v", dest, err)
}
// Read the contents of the source directory.
entries, err := os.ReadDir(source)
@@ -219,17 +182,6 @@ func copyDirectory(source, dest string) error {
srcPath := filepath.Join(source, entry.Name())
destPath := filepath.Join(dest, entry.Name())
if entry.Type()&os.ModeSymlink != 0 {
linkTarget := ""
linkTarget, err = os.Readlink(srcPath)
if err != nil {
return err
}
if err = os.Symlink(linkTarget, destPath); err != nil {
return err
}
continue
}
if entry.IsDir() {
// Recursively copy subdirectories.
err = copyDirectory(srcPath, destPath)
@@ -139,291 +139,6 @@ func TestCopyFilePreservesModTime(t *testing.T) {
}
}
// moveViaCrossDeviceFallback mirrors MoveFile's EXDEV fallback (CopyFile + RemoveAll).
func moveViaCrossDeviceFallback(t *testing.T, src, dst string) {
t.Helper()
if err := CopyFile(src, dst); err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(src); err != nil {
t.Fatal(err)
}
}
func TestMoveFileDoesNotFallbackOnNonCrossDeviceError(t *testing.T) {
srcDir := t.TempDir()
src := filepath.Join(srcDir, "file.txt")
if err := os.WriteFile(src, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
dst := filepath.Join(srcDir, "existing-dir")
if err := os.Mkdir(dst, 0o755); err != nil {
t.Fatal(err)
}
err := MoveFile(src, dst)
if err == nil {
t.Fatal("expected rename error")
}
if _, statErr := os.Stat(src); statErr != nil {
t.Fatalf("source file should remain after rename failure: %v", statErr)
}
entries, err := os.ReadDir(dst)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("destination directory should be unchanged, got %d entries", len(entries))
}
}
func TestMoveFileCrossDevicePreservesFilePermissions(t *testing.T) {
srcDir := t.TempDir()
dstDir := t.TempDir()
src := filepath.Join(srcDir, "secret.txt")
if err := os.WriteFile(src, []byte("data"), 0o640); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dstDir, "secret.txt")
moveViaCrossDeviceFallback(t, src, dst)
got, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if got.Mode().Perm() != 0o640 {
t.Fatalf("dst perm = %#o, want 0640", got.Mode().Perm())
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Fatalf("src should be removed, stat err = %v", err)
}
}
func TestMoveFileCrossDeviceMovesDirectory(t *testing.T) {
srcDir := t.TempDir()
dstParent := t.TempDir()
nested := filepath.Join(srcDir, "nested.txt")
if err := os.WriteFile(nested, []byte("nested"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(srcDir, 0o755); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dstParent, "moved-dir")
moveViaCrossDeviceFallback(t, srcDir, dst)
movedNested := filepath.Join(dst, "nested.txt")
if _, err := os.Stat(movedNested); err != nil {
t.Fatalf("expected nested file at destination: %v", err)
}
got, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if got.Mode().Perm() != 0o755 {
t.Fatalf("dst dir perm = %#o, want 0755", got.Mode().Perm())
}
if _, err := os.Stat(srcDir); !os.IsNotExist(err) {
t.Fatalf("source directory should be removed, stat err = %v", err)
}
}
func TestMoveFileCrossDevicePreservesSymlinkToFile(t *testing.T) {
targetDir := t.TempDir()
target := filepath.Join(targetDir, "target.txt")
if err := os.WriteFile(target, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
srcDir := t.TempDir()
dstDir := t.TempDir()
link := filepath.Join(srcDir, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dstDir, "link")
moveViaCrossDeviceFallback(t, link, dst)
gotTarget, err := os.Readlink(dst)
if err != nil {
t.Fatal(err)
}
if gotTarget != target {
t.Fatalf("link target = %q, want %q", gotTarget, target)
}
info, err := os.Lstat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("expected destination to remain a symlink")
}
if _, err := os.Stat(link); !os.IsNotExist(err) {
t.Fatalf("source symlink should be removed, stat err = %v", err)
}
}
func TestMoveFileCrossDevicePreservesSymlinkToDirectory(t *testing.T) {
targetDir := t.TempDir()
if err := os.Mkdir(filepath.Join(targetDir, "inside"), 0o755); err != nil {
t.Fatal(err)
}
srcDir := t.TempDir()
dstDir := t.TempDir()
link := filepath.Join(srcDir, "dirlink")
if err := os.Symlink(targetDir, link); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dstDir, "dirlink")
moveViaCrossDeviceFallback(t, link, dst)
gotTarget, err := os.Readlink(dst)
if err != nil {
t.Fatal(err)
}
if gotTarget != targetDir {
t.Fatalf("link target = %q, want %q", gotTarget, targetDir)
}
info, err := os.Lstat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("expected destination to remain a symlink")
}
}
func TestMoveFileCrossDeviceMovesDirectoryWithNestedSymlink(t *testing.T) {
target := filepath.Join(t.TempDir(), "target.txt")
if err := os.WriteFile(target, []byte("linked"), 0o644); err != nil {
t.Fatal(err)
}
srcDir := t.TempDir()
if err := os.Symlink(target, filepath.Join(srcDir, "nested-link")); err != nil {
t.Fatal(err)
}
dstParent := t.TempDir()
dst := filepath.Join(dstParent, "moved-dir")
moveViaCrossDeviceFallback(t, srcDir, dst)
movedLink := filepath.Join(dst, "nested-link")
gotTarget, err := os.Readlink(movedLink)
if err != nil {
t.Fatal(err)
}
if gotTarget != target {
t.Fatalf("nested link target = %q, want %q", gotTarget, target)
}
info, err := os.Lstat(movedLink)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("expected nested entry to remain a symlink")
}
}
func TestCopySymlinkReplacesExistingDestination(t *testing.T) {
target := filepath.Join(t.TempDir(), "target.txt")
if err := os.WriteFile(target, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
workDir := t.TempDir()
src := filepath.Join(workDir, "link")
if err := os.Symlink(target, src); err != nil {
t.Fatal(err)
}
dst := filepath.Join(workDir, "existing")
if err := os.WriteFile(dst, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
if err := copySymlink(src, dst); err != nil {
t.Fatal(err)
}
gotTarget, err := os.Readlink(dst)
if err != nil {
t.Fatal(err)
}
if gotTarget != target {
t.Fatalf("link target = %q, want %q", gotTarget, target)
}
info, err := os.Lstat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("expected destination to be a symlink")
}
}
func TestCopyFile_ReplacesDestinationSymlinkWithoutFollowingTarget(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(filepath.Dir(dir), "outside-victim.txt")
if err := os.WriteFile(outside, []byte("keep me"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
src := filepath.Join(dir, "src.txt")
if err := os.WriteFile(src, []byte("copied"), 0o644); err != nil {
t.Fatal(err)
}
if err := CopyFile(src, link); err != nil {
t.Fatalf("CopyFile: %v", err)
}
info, err := os.Lstat(link)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink != 0 {
t.Fatal("destination should be a regular file replacing the symlink entry")
}
got, err := os.ReadFile(outside)
if err != nil || string(got) != "keep me" {
t.Fatalf("copy followed the symlink target: %v %q", err, got)
}
if body, err := os.ReadFile(link); err != nil || string(body) != "copied" {
t.Fatalf("link path content = %q, err=%v", body, err)
}
}
func TestMoveFile_ReplacesDestinationSymlinkWithoutFollowingTarget(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(filepath.Dir(dir), "outside-victim.txt")
if err := os.WriteFile(outside, []byte("keep me"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
src := filepath.Join(dir, "src.txt")
if err := os.WriteFile(src, []byte("moved"), 0o644); err != nil {
t.Fatal(err)
}
if err := MoveFile(src, link); err != nil {
t.Fatalf("MoveFile: %v", err)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatal("source should be gone after move")
}
info, err := os.Lstat(link)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink != 0 {
t.Fatal("destination should be a regular file replacing the symlink entry")
}
got, err := os.ReadFile(outside)
if err != nil || string(got) != "keep me" {
t.Fatalf("move followed the symlink target: %v %q", err, got)
}
}
func TestCopyFileContentsFallsBackAfterCopyFileRangeBadFileDescriptor(t *testing.T) {
const content = "complete file contents"
src := struct{ io.Reader }{bytes.NewBufferString(content)}
@@ -1,30 +0,0 @@
package fileutils
// PartitionUsage is a capacity view of a source path: total and used bytes.
type PartitionUsage struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
}
// singlePathPartitionUsage statfs's a single path: the filesystem covering it.
func singlePathPartitionUsage(path string) (PartitionUsage, error) {
total, err := GetPartitionSize(path)
if err != nil {
return PartitionUsage{}, err
}
used, err := GetPartitionUsed(path)
if err != nil {
return PartitionUsage{}, err
}
return PartitionUsage{Total: total, Used: used}, nil
}
// GetAggregatedPartitionUsage returns total and used bytes summed across distinct
// capacity groups mounted at or under root. See GetPartitionUsageVariants.
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
aggregate, _, err := GetPartitionUsageVariants(root)
if err != nil {
return 0, 0, err
}
return aggregate.Total, aggregate.Used, nil
}
@@ -1,188 +0,0 @@
//go:build freebsd
package fileutils
import (
"fmt"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
// bsdMount is one Getfsstat record reduced to what capacity aggregation
// needs: fstype, source, mountpoint, usage, and the mount's own fsid.
type bsdMount struct {
fstype string
source string
mountpoint string
usage rawUsage
fsid unix.Fsid
}
// resolvedMount is what a path lookup reports for a mountpoint: the
// mountpoint of the filesystem that actually owns the path and that
// filesystem's fsid.
type resolvedMount struct {
mountpoint string
fsid unix.Fsid
ok bool
}
// statfsMountpoint resolves which filesystem a mountpoint path lands on.
func statfsMountpoint(mp string) resolvedMount {
var st unix.Statfs_t
if err := unix.Statfs(mp, &st); err != nil {
return resolvedMount{}
}
return resolvedMount{
mountpoint: filepath.Clean(unix.ByteSliceToString(st.Mntonname[:])),
fsid: st.Fsid,
ok: true,
}
}
// GetPartitionUsageVariants returns the aggregate and root-only capacity views
// of root. FreeBSD has no mountinfo; Getfsstat already carries per-mount statfs
// data, so grouping happens on the result set directly (ZFS datasets grouped by
// pool name, everything else by fsid).
func GetPartitionUsageVariants(root string) (aggregate, rootOnly PartitionUsage, err error) {
if root == "" {
return aggregate, rootOnly, fmt.Errorf("empty path")
}
root = filepath.Clean(root)
rootOnly, rootErr := singlePathPartitionUsage(root)
count, statErr := unix.Getfsstat(nil, unix.MNT_WAIT)
if statErr != nil || count <= 0 {
if rootErr != nil {
return aggregate, rootOnly, rootErr
}
return rootOnly, rootOnly, nil
}
stats := make([]unix.Statfs_t, count)
if _, statErr = unix.Getfsstat(stats, unix.MNT_WAIT); statErr != nil {
if rootErr != nil {
return aggregate, rootOnly, rootErr
}
return rootOnly, rootOnly, nil
}
var mounts []bsdMount
for i := range stats {
s := &stats[i]
avail := uint64(0)
if s.Bavail > 0 {
avail = uint64(s.Bavail)
}
mounts = append(mounts, bsdMount{
fstype: unix.ByteSliceToString(s.Fstypename[:]),
source: unix.ByteSliceToString(s.Mntfromname[:]),
mountpoint: filepath.Clean(unix.ByteSliceToString(s.Mntonname[:])),
usage: rawUsage{
total: s.Blocks * s.Bsize,
avail: avail * s.Bsize,
},
fsid: s.Fsid,
})
}
covering, nested := selectCapacityMounts(mounts, root, statfsMountpoint)
key := func(m bsdMount) string {
if isZFSType(m.fstype) {
return "zfs:" + zfsPoolName(m.source)
}
if m.fsid.Val[0] == 0 && m.fsid.Val[1] == 0 {
// fsid-less filesystems (e.g. some tmpfs mounts): keep per mountpoint.
return "mp:" + m.mountpoint
}
return fmt.Sprintf("fsid:%x:%x", m.fsid.Val[0], m.fsid.Val[1])
}
groups := make(map[string][]rawUsage)
fstypes := make(map[string]string)
add := func(m bsdMount) {
k := key(m)
fstypes[k] = m.fstype
groups[k] = append(groups[k], m.usage)
}
if covering != nil {
add(*covering)
}
for _, m := range nested {
add(m)
}
if len(groups) == 0 {
if rootErr != nil {
return aggregate, rootOnly, rootErr
}
return rootOnly, rootOnly, nil
}
for k, stats := range groups {
u := combineGroupUsage(stats)
if !isZFSType(fstypes[k]) && len(stats) > 1 {
// Non-shared-pool mounts dedupe to a single measurement.
u = combineGroupUsage(stats[:1])
}
aggregate.Total += u.Total
aggregate.Used += u.Used
}
return aggregate, rootOnly, rootErr
}
// selectCapacityMounts picks the mounts contributing capacity under root: the
// filesystem covering root plus mounts nested beneath it.
//
// Getfsstat retains records that path lookups no longer reach: mounts stacked
// under a later mount at the same mountpoint, and mounts below an overmounted
// ancestor (stacked records have no documented order, so position cannot
// identify the visible one). A record counts only when a lookup on its
// mountpoint still lands on it: the resolved mountpoint must equal the
// record's and, for filesystems carrying an fsid, the resolved fsid must match
// the record's (every stacked record resolves to the topmost fsid). fsid-less
// records keep the first match at a mountpoint; stacked fsid-less mounts are
// indistinguishable.
func selectCapacityMounts(mounts []bsdMount, root string, lookup func(string) resolvedMount) (*bsdMount, []bsdMount) {
var covering *bsdMount
var nested []bsdMount
claimed := make(map[string]bool)
resolved := make(map[string]resolvedMount)
for i := range mounts {
m := mounts[i]
covers := m.mountpoint == root || m.mountpoint == "/" || strings.HasPrefix(root, m.mountpoint+"/")
nests := strings.HasPrefix(m.mountpoint, root+"/")
if !covers && !nests {
continue
}
r, seen := resolved[m.mountpoint]
if !seen {
r = lookup(m.mountpoint)
resolved[m.mountpoint] = r
}
if !r.ok || r.mountpoint != m.mountpoint {
continue
}
if m.fsid.Val[0] != 0 || m.fsid.Val[1] != 0 {
if r.fsid != m.fsid {
continue
}
} else if claimed[m.mountpoint] {
continue
}
claimed[m.mountpoint] = true
if covers && (covering == nil || len(m.mountpoint) > len(covering.mountpoint)) {
c := m
covering = &c
}
if nests {
if isZFSType(m.fstype) && isZFSSnapshotMount(m.source, m.mountpoint) {
continue
}
nested = append(nested, m)
}
}
return covering, nested
}
@@ -1,161 +0,0 @@
//go:build freebsd
package fileutils
import (
"testing"
"golang.org/x/sys/unix"
)
func fsidVal(a, b int32) unix.Fsid {
return unix.Fsid{Val: [2]int32{a, b}}
}
func resolvedOK(mp string, fsid unix.Fsid) resolvedMount {
return resolvedMount{mountpoint: mp, fsid: fsid, ok: true}
}
// TestSelectCapacityMountsOvermountedAncestorHidesNested reproduces the
// reviewed layout: /srv/child was mounted first, then /srv was overmounted.
// Getfsstat still reports the unique /srv/child record, but a lookup of that
// path lands inside the covering filesystem, so its usage must be excluded.
func TestSelectCapacityMountsOvermountedAncestorHidesNested(t *testing.T) {
rootFs, childFs, coverFs := fsidVal(1, 1), fsidVal(2, 2), fsidVal(3, 3)
mounts := []bsdMount{
{fstype: "ufs", source: "/dev/da0p2", mountpoint: "/", fsid: rootFs, usage: rawUsage{total: 100, avail: 50}},
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/srv/child", fsid: childFs, usage: rawUsage{total: 200, avail: 100}},
{fstype: "ufs", source: "/dev/da2p1", mountpoint: "/srv", fsid: coverFs, usage: rawUsage{total: 300, avail: 150}},
}
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/srv":
return resolvedOK("/srv", coverFs)
case "/srv/child":
// Hidden: resolves to the covering filesystem at /srv.
return resolvedOK("/srv", coverFs)
}
return resolvedMount{}
}
covering, nested := selectCapacityMounts(mounts, "/srv", lookup)
if covering == nil || covering.mountpoint != "/srv" {
t.Fatalf("covering = %+v, want mountpoint /srv", covering)
}
if len(nested) != 0 {
t.Fatalf("nested = %+v, want hidden /srv/child excluded", nested)
}
}
// TestSelectCapacityMountsUnreachableNestedSkipped covers the same hidden
// layout when the covered path no longer exists in the new filesystem: the
// lookup fails outright and the stale record is dropped.
func TestSelectCapacityMountsUnreachableNestedSkipped(t *testing.T) {
rootFs, coverFs := fsidVal(1, 1), fsidVal(3, 3)
mounts := []bsdMount{
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
{fstype: "ufs", source: "/dev/da2p1", mountpoint: "/srv", fsid: coverFs},
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/srv/gone", fsid: fsidVal(2, 2), usage: rawUsage{total: 200, avail: 100}},
}
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/srv":
return resolvedOK("/srv", coverFs)
}
return resolvedMount{} // ENOENT under the overmount
}
_, nested := selectCapacityMounts(mounts, "/srv", lookup)
if len(nested) != 0 {
t.Fatalf("nested = %+v, want unreachable /srv/gone excluded", nested)
}
}
// TestSelectCapacityMountsStackedTopmostOnly checks that of several records
// at one mountpoint only the one a lookup resolves to is counted.
func TestSelectCapacityMountsStackedTopmostOnly(t *testing.T) {
rootFs, lowerFs, upperFs := fsidVal(1, 1), fsidVal(4, 4), fsidVal(5, 5)
mounts := []bsdMount{
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/srv/data", fsid: lowerFs, usage: rawUsage{total: 200, avail: 100}},
{fstype: "tmpfs", source: "tmpfs", mountpoint: "/srv/data", fsid: upperFs, usage: rawUsage{total: 60, avail: 30}},
}
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/srv/data":
return resolvedOK("/srv/data", upperFs)
}
return resolvedMount{}
}
_, nested := selectCapacityMounts(mounts, "/srv", lookup)
if len(nested) != 1 || nested[0].fsid != upperFs {
t.Fatalf("nested = %+v, want only the topmost /srv/data record", nested)
}
}
// TestSelectCapacityMountsHiddenCoveringAncestor: when the deepest ancestor
// mount of root is itself hidden by an overmount, covering must fall back to
// the deepest visible ancestor.
func TestSelectCapacityMountsHiddenCoveringAncestor(t *testing.T) {
rootFs, overFs, staleFs := fsidVal(1, 1), fsidVal(8, 8), fsidVal(9, 9)
mounts := []bsdMount{
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
{fstype: "ufs", source: "/dev/da2p1", mountpoint: "/x", fsid: overFs},
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/x/y", fsid: staleFs, usage: rawUsage{total: 200, avail: 100}},
}
// /x was mounted over the parent chain of /x/y: lookups of /x/y resolve
// inside the /x filesystem.
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/x":
return resolvedOK("/x", overFs)
case "/x/y":
return resolvedOK("/x", overFs)
}
return resolvedMount{}
}
covering, nested := selectCapacityMounts(mounts, "/x/y/z", lookup)
if covering == nil || covering.mountpoint != "/x" {
t.Fatalf("covering = %+v, want deepest visible ancestor /x", covering)
}
if len(nested) != 0 {
t.Fatalf("nested = %+v, want none", nested)
}
}
// TestSelectCapacityMountsNestedVisibleKept is the baseline: a nested mount
// that still resolves to itself contributes its usage.
func TestSelectCapacityMountsNestedVisibleKept(t *testing.T) {
rootFs, zfsFs := fsidVal(1, 1), fsidVal(7, 7)
mounts := []bsdMount{
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
{fstype: "zfs", source: "tank/data", mountpoint: "/srv/data", fsid: zfsFs, usage: rawUsage{total: 400, avail: 200}},
}
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/srv/data":
return resolvedOK("/srv/data", zfsFs)
}
return resolvedMount{}
}
covering, nested := selectCapacityMounts(mounts, "/srv", lookup)
if covering == nil || covering.mountpoint != "/" {
t.Fatalf("covering = %+v, want /", covering)
}
if len(nested) != 1 || nested[0].mountpoint != "/srv/data" {
t.Fatalf("nested = %+v, want [/srv/data]", nested)
}
}
@@ -7,64 +7,94 @@ import (
"os"
"path/filepath"
"syscall"
"golang.org/x/sys/unix"
)
// GetPartitionUsageVariants returns two capacity views of root:
// - aggregate: total/used summed across distinct capacity groups mounted at or
// under root, deduped so shared-pool filesystems (ZFS datasets, btrfs
// subvolumes) are counted once per pool.
// - rootOnly: a single statfs of root, ignoring nested mounts.
//
// Falls back to the single-path probe for both when mountinfo cannot be read.
func GetPartitionUsageVariants(root string) (aggregate, rootOnly PartitionUsage, err error) {
// 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 aggregate, rootOnly, fmt.Errorf("empty path")
return 0, 0, fmt.Errorf("empty path")
}
root = filepath.Clean(root)
rootOnly, rootErr := singlePathPartitionUsage(root)
groups, parseErr := mountGroupsUnder(root)
if parseErr != nil || len(groups) == 0 {
if rootErr != nil {
return aggregate, rootOnly, rootErr
}
return rootOnly, rootOnly, nil
paths, parseErr := distinctMountPathsUnder(root)
if parseErr != nil || len(paths) == 0 {
return singlePathPartitionUsage(root)
}
var firstErr error
for _, group := range groups {
stats := make([]rawUsage, 0, len(group.paths))
for _, p := range group.paths {
stat, stErr := partitionStatfs(p)
if stErr != nil {
if firstErr == nil {
firstErr = stErr
}
continue
for _, path := range paths {
t, u, stErr := partitionUsageAt(path)
if stErr != nil {
if firstErr == nil {
firstErr = stErr
}
stats = append(stats, stat)
continue
}
u := combineGroupUsage(stats)
aggregate.Total += u.Total
aggregate.Used += u.Used
total += t
used += u
}
if aggregate.Total == 0 && aggregate.Used == 0 {
if total == 0 && used == 0 {
if firstErr != nil {
return aggregate, rootOnly, firstErr
return 0, 0, firstErr
}
aggregate = rootOnly
return singlePathPartitionUsage(root)
}
return aggregate, rootOnly, rootErr
return total, used, nil
}
func mountGroupsUnder(root string) ([]mountGroup, error) {
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
}
return mountGroupsFromMountinfo(data, root)
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) {
@@ -77,14 +107,24 @@ func readMountinfo() (string, error) {
return "", fmt.Errorf("mountinfo not available")
}
func partitionStatfs(path string) (rawUsage, error) {
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 rawUsage{}, err
return 0, 0, err
}
bsize := blockSize(&stat)
return rawUsage{
total: uint64(stat.Blocks) * bsize,
avail: uint64(stat.Bavail) * bsize,
}, nil
total = uint64(stat.Blocks) * bsize
free := uint64(stat.Bavail) * bsize
if total >= free {
used = total - free
}
return total, used, nil
}
@@ -12,111 +12,55 @@ import (
type mountEntry struct {
deviceID string
mountpoint string
fstype string
source string
}
// mountGroup is a set of mountpoints that share one capacity pool. Groups keyed
// by filesystem identity get one probe path; shared-pool filesystems (zfs) keep
// every dataset mountpoint so per-dataset used space can be summed.
type mountGroup struct {
key string
fstype string
paths []string
}
// capacityKey identifies mounts that share the same underlying capacity.
// - zfs: every dataset has its own device ID but draws from the pool; group by pool.
// - btrfs: subvolume mounts have distinct device IDs but report the whole
// filesystem; group by mount source.
// - everything else: device major:minor, as before.
func (e *mountEntry) capacityKey() string {
switch e.fstype {
case "zfs", "zfsfuse":
return "zfs:" + zfsPoolName(e.source)
case "btrfs":
return "btrfs:" + e.source
default:
return "dev:" + e.deviceID
}
}
// mountGroupsFromMountinfo selects the mounts contributing capacity for root:
// the filesystem that owns root, plus nested mounts under root, grouped by
// capacity key. ZFS snapshot mounts are excluded.
func mountGroupsFromMountinfo(mountinfo string, root string) ([]mountGroup, error) {
// 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
}
groupIndex := make(map[string]int)
var groups []mountGroup
addPath := func(e *mountEntry, p string) {
key := e.capacityKey()
i, ok := groupIndex[key]
if !ok {
i = len(groups)
groupIndex[key] = i
groups = append(groups, mountGroup{key: key, fstype: e.fstype})
}
// Only shared-pool filesystems (zfs) need every mountpoint; other groups
// report identical statfs per mount, so one probe path suffices.
if !isZFSType(groups[i].fstype) && len(groups[i].paths) > 0 {
return
}
for _, existing := range groups[i].paths {
if existing == p {
return
}
}
groups[i].paths = append(groups[i].paths, p)
}
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 == "/" {
// >= keeps the later entry: stacked mounts at one mountpoint are
// shadowed by the topmost (last in mountinfo order).
if covering == nil || len(mp) >= len(covering.mountpoint) {
if covering == nil || len(mp) > len(covering.mountpoint) {
covering = e
}
}
}
if covering != nil {
// Probe root itself for the covering filesystem: root may be a plain
// directory inside a mount whose mountpoint is an ancestor.
addPath(covering, root)
} else {
// No mount covers root (e.g. hidden bind mount); probe it directly so
// it is not dropped when nested mounts under root do exist.
groups = append(groups, mountGroup{key: "self:" + root, paths: []string{root}})
seen[covering.deviceID] = root
}
// Stacked mounts: only the topmost (last in mountinfo order) is reachable.
lastAtMountpoint := make(map[string]int)
for i := range entries {
lastAtMountpoint[entries[i].mountpoint] = i
}
for i := range entries {
e := &entries[i]
for _, e := range entries {
mp := e.mountpoint
if mp != root && !strings.HasPrefix(mp, root+"/") {
continue
}
if lastAtMountpoint[mp] != i {
if _, ok := seen[e.deviceID]; ok {
continue
}
if isZFSType(e.fstype) && isZFSSnapshotMount(e.source, mp) {
continue
}
addPath(e, mp)
seen[e.deviceID] = mp
}
return groups, nil
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) {
@@ -144,16 +88,9 @@ func parseMountinfoLine(line string) (mountEntry, bool) {
if len(fields) < 5 {
return mountEntry{}, false
}
// After " - ": "fstype source super-options".
post := strings.Fields(parts[1])
if len(post) < 2 {
return mountEntry{}, false
}
return mountEntry{
deviceID: fields[2],
mountpoint: unescapeFstab(fields[4]),
fstype: post[0],
source: unescapeFstab(post[1]),
}, true
}
@@ -7,25 +7,6 @@ import (
"testing"
)
// allGroupPaths flattens groups into a sorted list of probe paths.
func allGroupPaths(groups []mountGroup) []string {
var out []string
for _, g := range groups {
out = append(out, g.paths...)
}
sort.Strings(out)
return out
}
func findGroup(groups []mountGroup, prefix string) *mountGroup {
for i := range groups {
if len(groups[i].key) >= len(prefix) && groups[i].key[:len(prefix)] == prefix {
return &groups[i]
}
}
return nil
}
func TestDistinctMountPathsNestedDisk(t *testing.T) {
// /srv lives on rootfs 8:1; /srv/data is a separate 2TB disk 8:16.
mountinfo := "" +
@@ -33,11 +14,11 @@ func TestDistinctMountPathsNestedDisk(t *testing.T) {
"45 22 8:16 /data /srv/data rw - btrfs /dev/sdb1 rw\n" +
"50 22 0:47 / /proc rw - proc proc rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
paths := allGroupPaths(groups)
sort.Strings(paths)
want := []string{"/srv", "/srv/data"}
if len(paths) != len(want) {
t.Fatalf("paths = %v, want %v", paths, want)
@@ -56,11 +37,11 @@ func TestDistinctMountPathsOvermountSamePathDeduped(t *testing.T) {
"45 40 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n" +
"46 45 0:47 / /srv/data rw - tmpfs tmpfs rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
paths := allGroupPaths(groups)
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)
@@ -75,15 +56,15 @@ func TestDistinctMountPathsOvermountSamePathDeduped(t *testing.T) {
func TestDistinctMountPathsBindSameDeviceNotDuplicated(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"45 22 8:16 / /srv/data rw - ext4 /dev/sdb1 rw\n" +
"46 22 8:16 / /srv/data/bind rw,bind - ext4 /dev/sdb1 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"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
if len(groups) != 2 {
t.Fatalf("expected 2 distinct capacity groups, got %v", groups)
if len(paths) != 2 {
t.Fatalf("expected 2 distinct devices, got %v", paths)
}
}
@@ -93,11 +74,11 @@ func TestDistinctMountPathsSourceIsOwnMount(t *testing.T) {
"40 22 8:2 / /srv rw - ext4 /dev/sda2 rw\n" +
"45 40 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
paths := allGroupPaths(groups)
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)
@@ -109,11 +90,10 @@ func TestDistinctMountPathsIgnoresUnrelated(t *testing.T) {
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"30 22 8:3 / /home rw - ext4 /dev/sda3 rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
paths := allGroupPaths(groups)
if len(paths) != 1 || paths[0] != "/srv" {
t.Fatalf("paths = %v, want [/srv]", paths)
}
@@ -130,124 +110,4 @@ func TestParseMountinfoEscapedSpace(t *testing.T) {
if entry.deviceID != "98:0" {
t.Fatalf("deviceID = %q, want 98:0", entry.deviceID)
}
if entry.fstype != "ext3" {
t.Fatalf("fstype = %q, want ext3", entry.fstype)
}
if entry.source != "/dev/root" {
t.Fatalf("source = %q, want /dev/root", entry.source)
}
}
// TestMountGroupsZFSPoolDatasetsGrouped reproduces issue #3025: every ZFS
// dataset gets its own device ID, so major:minor dedup counts pool capacity
// once per dataset. They must share one capacity group.
func TestMountGroupsZFSPoolDatasetsGrouped(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"40 22 0:55 / /srv/pool rw - zfs pool rw\n" +
"41 40 0:56 / /srv/pool/ds01 rw - zfs pool/ds01 rw\n" +
"42 41 0:57 / /srv/pool/ds01/a rw - zfs pool/ds01/a rw\n" +
"43 40 0:58 / /srv/pool/ds02 rw - zfs pool/ds02 rw\n" +
"44 22 0:59 / /other/pool2 rw - zfs pool2 rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv/pool")
if err != nil {
t.Fatal(err)
}
g := findGroup(groups, "zfs:pool")
if g == nil || g.key != "zfs:pool" {
t.Fatalf("expected one zfs:pool group, got %v", groups)
}
want := []string{"/srv/pool", "/srv/pool/ds01", "/srv/pool/ds01/a", "/srv/pool/ds02"}
got := append([]string{}, g.paths...)
sort.Strings(got)
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("zfs group paths = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("zfs group paths = %v, want %v", got, want)
}
}
// The unrelated second pool must not join the group.
if g2 := findGroup(groups, "zfs:pool2"); g2 != nil {
t.Fatalf("unrelated pool2 should not be included: %v", groups)
}
}
func TestMountGroupsZFSSnapshotsSkipped(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"40 22 0:55 / /srv/pool rw - zfs pool rw\n" +
"41 40 0:56 / /srv/pool/ds01 rw - zfs pool/ds01 rw\n" +
"42 41 0:57 / /srv/pool/ds01/.zfs/snapshot/snap1 rw - zfs pool/ds01@snap1 rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv/pool")
if err != nil {
t.Fatal(err)
}
paths := allGroupPaths(groups)
for _, p := range paths {
if p == "/srv/pool/ds01/.zfs/snapshot/snap1" {
t.Fatalf("snapshot mount should be skipped: %v", paths)
}
}
if len(paths) != 2 {
t.Fatalf("paths = %v, want 2 live datasets", paths)
}
}
func TestMountGroupsBtrfsSubvolumesDeduped(t *testing.T) {
// Each btrfs subvolume mount has a distinct anonymous device but reports
// whole-filesystem statfs; they must collapse into one group.
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"40 22 0:33 /@ /srv rw - btrfs /dev/sdb1 rw\n" +
"41 40 0:34 /@home /srv/home rw - btrfs /dev/sdb1 rw\n" +
"42 40 0:35 /@data /srv/data rw - btrfs /dev/sdb1 rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
g := findGroup(groups, "btrfs:")
if g == nil || g.key != "btrfs:/dev/sdb1" {
t.Fatalf("expected one btrfs group, got %v", groups)
}
// Subvolume mounts report identical fs-wide statfs; one probe path suffices.
if len(g.paths) != 1 || g.paths[0] != "/srv" {
t.Fatalf("btrfs group paths = %v, want [/srv]", g.paths)
}
if len(groups) != 1 {
t.Fatalf("expected exactly 1 group for /srv, got %v", groups)
}
}
// TestCombineGroupUsageZFSPool verifies the arithmetic from #3025: 26 datasets
// each reporting ~19.7T avail must yield used+19.7T total, not 26*19.7T+used.
func TestCombineGroupUsageZFSPool(t *testing.T) {
const avail = uint64(19_700_000_000_000) // ~19.7 TiB pool free
stats := []rawUsage{{total: 20_000_000_000_000, avail: avail}} // root ds: 0.3T used
for i := 0; i < 25; i++ {
stats = append(stats, rawUsage{total: avail + 100_000_000_000, avail: avail})
}
// One quota'd child: reports a smaller avail; must not shrink the pool.
stats = append(stats, rawUsage{total: 1_000_000_000_000, avail: 500_000_000_000})
u := combineGroupUsage(stats)
wantUsed := uint64(300_000_000_000) + 25*uint64(100_000_000_000) + 500_000_000_000
if u.Used != wantUsed {
t.Fatalf("used = %d, want %d", u.Used, wantUsed)
}
if u.Total != wantUsed+avail {
t.Fatalf("total = %d, want %d (used+single pool avail)", u.Total, wantUsed+avail)
}
}
func TestCombineGroupUsageSingleMountIdentity(t *testing.T) {
u := combineGroupUsage([]rawUsage{{total: 1000, avail: 400}})
if u.Total != 1000 || u.Used != 600 {
t.Fatalf("single mount = %+v, want total 1000 used 600", u)
}
}
@@ -1,14 +1,17 @@
//go:build !linux && !freebsd
//go:build !linux
package fileutils
// GetPartitionUsageVariants returns the aggregate and root-only capacity views
// of root. Without mount info only a single statfs of the source path is
// available, so both variants are identical.
func GetPartitionUsageVariants(root string) (aggregate, rootOnly PartitionUsage, err error) {
u, err := singlePathPartitionUsage(root)
// 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 aggregate, rootOnly, err
return 0, 0, err
}
return u, u, nil
used, err = GetPartitionUsed(root)
if err != nil {
return 0, 0, err
}
return total, used, nil
}
@@ -1,50 +0,0 @@
//go:build linux || freebsd
package fileutils
import "strings"
// rawUsage is a single filesystem measurement in bytes.
type rawUsage struct {
total uint64
avail uint64
}
// combineGroupUsage folds statfs results for mounts that share one capacity pool
// into a single total/used pair. Per-mount used (total-avail) is summed, while
// shared free space is counted once via the largest reported avail. This keeps
// ZFS datasets or btrfs subvolumes from multiplying the pool's capacity by the
// number of mounts under a source.
func combineGroupUsage(stats []rawUsage) PartitionUsage {
var used, avail uint64
for _, s := range stats {
if s.total > s.avail {
used += s.total - s.avail
}
if s.avail > avail {
avail = s.avail
}
}
return PartitionUsage{Total: used + avail, Used: used}
}
// isZFSType reports whether fstype is a ZFS variant whose datasets share pool
// capacity across mounts (kernel zfs or zfsfuse).
func isZFSType(fstype string) bool {
return fstype == "zfs" || fstype == "zfsfuse"
}
// zfsPoolName returns the pool component of a ZFS mount source ("pool/ds@snap" -> "pool").
func zfsPoolName(source string) string {
if i := strings.IndexAny(source, "/@"); i >= 0 {
return source[:i]
}
return source
}
// isZFSSnapshotMount reports whether a ZFS mount is a snapshot (.zfs snapdir or
// an "@snapshot" source). Snapshots share pool space with the live datasets and
// are excluded from capacity aggregation.
func isZFSSnapshotMount(source, mountpoint string) bool {
return strings.Contains(source, "@") || strings.Contains(mountpoint, "/.zfs/")
}
@@ -1,17 +0,0 @@
//go:build !windows
package fileutils
import (
"errors"
"os"
"syscall"
)
func isCrossDeviceRenameError(err error) bool {
if errors.Is(err, syscall.EXDEV) {
return true
}
var linkErr *os.LinkError
return errors.As(err, &linkErr) && errors.Is(linkErr.Err, syscall.EXDEV)
}
@@ -1,18 +0,0 @@
//go:build windows
package fileutils
import (
"errors"
"os"
"syscall"
)
func isCrossDeviceRenameError(err error) bool {
const errNotSameDevice = syscall.Errno(17)
if errors.Is(err, errNotSameDevice) {
return true
}
var linkErr *os.LinkError
return errors.As(err, &linkErr) && errors.Is(linkErr.Err, errNotSameDevice)
}
-12
View File
@@ -1,12 +0,0 @@
package analytics
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
+1 -1
View File
@@ -301,7 +301,7 @@ func detectRuntime() string {
func mapSystemOS(goos string) string {
switch goos {
case "linux", "windows", "darwin", "freebsd":
case "linux", "windows", "darwin":
return goos
default:
return "other"
@@ -1,25 +0,0 @@
package analytics
import "testing"
func TestMapSystemOS(t *testing.T) {
tests := []struct {
name string
goos string
want string
}{
{name: "linux", goos: "linux", want: "linux"},
{name: "windows", goos: "windows", want: "windows"},
{name: "darwin", goos: "darwin", want: "darwin"},
{name: "freebsd release build", goos: "freebsd", want: "freebsd"},
{name: "unsupported goos", goos: "plan9", want: "other"},
{name: "empty", goos: "", want: "other"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mapSystemOS(tt.goos); got != tt.want {
t.Errorf("mapSystemOS(%q) = %q, want %q", tt.goos, got, tt.want)
}
})
}
}
-213
View File
@@ -1,213 +0,0 @@
// Package archiveencoding decodes ZIP entry names before path normalization.
package archiveencoding
import (
"archive/zip"
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/encoding/korean"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/encoding/traditionalchinese"
)
// ErrEncoding indicates a filename encoding that needs a different user selection.
var ErrEncoding = errors.New("invalid ZIP filename encoding")
type codec struct {
name string
encoding encoding.Encoding
}
var codecs = []codec{
{"utf-8", nil},
{"cp932", japanese.ShiftJIS},
{"gb18030", simplifiedchinese.GB18030},
{"big5", traditionalchinese.Big5},
{"euc-kr", korean.EUCKR},
{"cp437", charmap.CodePage437},
{"windows-1252", charmap.Windows1252},
}
// Candidate contains a bounded preview, decoded exactly as during extraction.
type Candidate struct {
Encoding string `json:"encoding"`
Names []string `json:"names"`
}
// Preview describes compatible encodings. An empty suggestion requires a choice;
// a heuristic suggestion is never a guarantee that the names are correct.
type Preview struct {
Suggested string `json:"suggested"`
Candidates []Candidate `json:"candidates"`
}
// unicodeName prefers the UTF-8 flag, then a verified Info-ZIP Unicode Path field.
// A Unicode Path field is usable only when its version and original-name CRC match.
func unicodeName(f *zip.File) (string, bool) {
if f.Flags&0x800 != 0 {
return f.Name, true
}
extra := f.Extra
for len(extra) >= 4 {
tag := binary.LittleEndian.Uint16(extra)
size := int(binary.LittleEndian.Uint16(extra[2:]))
extra = extra[4:]
if size > len(extra) {
break
}
field := extra[:size]
extra = extra[size:]
if tag == 0x7075 && len(field) > 5 && field[0] == 1 &&
binary.LittleEndian.Uint32(field[1:5]) == crc32.ChecksumIEEE([]byte(f.Name)) && utf8.Valid(field[5:]) {
return string(field[5:]), true
}
}
return "", false
}
func decode(f *zip.File, c codec) (string, error) {
name, declared := unicodeName(f)
if !declared {
name = f.Name
if c.encoding != nil {
var err error
name, err = c.encoding.NewDecoder().String(name)
if err != nil || strings.ContainsRune(name, utf8.RuneError) {
return "", fmt.Errorf("%w: cannot decode filename as %s", ErrEncoding, c.name)
}
}
}
if !utf8.ValidString(name) || strings.ContainsRune(name, 0) {
return "", fmt.Errorf("%w: invalid filename for %s", ErrEncoding, c.name)
}
return name, nil
}
// Inspect considers all entry names without decompressing file contents. Candidate
// previews prefer names lacking Unicode metadata so that users can compare them.
func Inspect(files []*zip.File) Preview {
result := Preview{Candidates: []Candidate{}}
bestScore, tied := 0, false
for _, c := range codecs {
candidate := Candidate{Encoding: c.name, Names: []string{}}
fallback := []string{}
score, valid := 0, true
for _, f := range files {
name, err := decode(f, c)
if err != nil {
valid = false
break
}
if len(fallback) < 5 {
fallback = append(fallback, name)
}
if _, declared := unicodeName(f); !declared && !isASCII(f.Name) {
if len(candidate.Names) < 5 {
candidate.Names = append(candidate.Names, name)
}
// The EUC-KR decoder also accepts CP949 extension bytes, including
// CP932 kana pairs. Do not use those bytes as Korean evidence.
if c.name != "euc-kr" || isEUCKR(f.Name) {
score += scriptEvidence(name, c.name)
}
}
}
if !valid {
continue
}
if len(candidate.Names) == 0 {
candidate.Names = fallback
}
result.Candidates = append(result.Candidates, candidate)
// Valid UTF-8 (including metadata-backed names) needs no legacy guess.
if c.name == "utf-8" {
result.Suggested = c.name
}
if score > bestScore {
bestScore, tied = score, false
if result.Suggested != "utf-8" {
result.Suggested = c.name
}
} else if score == bestScore && score > 0 {
tied = true
}
}
if result.Suggested != "utf-8" && (bestScore < 3 || tied) {
result.Suggested = ""
}
return result
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= utf8.RuneSelf {
return false
}
}
return true
}
// isEUCKR excludes CP949 extension sequences from automatic Korean suggestions.
func isEUCKR(raw string) bool {
for i := 0; i < len(raw); i++ {
if raw[i] < utf8.RuneSelf {
continue
}
if raw[i] < 0xa1 || raw[i] > 0xfe || i+1 >= len(raw) || raw[i+1] < 0xa1 || raw[i+1] > 0xfe {
return false
}
i++
}
return true
}
// Kana and Hangul give useful script evidence; Han alone does not distinguish
// Japanese, simplified Chinese and traditional Chinese. Half-width kana are not
// evidence: unrelated legacy bytes frequently decode to half-width kana.
func scriptEvidence(name, charset string) int {
score := 0
for _, r := range name {
if charset == "cp932" && r < 0xff00 && (unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r)) {
score++
}
if charset == "euc-kr" && unicode.Is(unicode.Hangul, r) {
score++
}
}
return score
}
// DecodeNames resolves the selection once for the entire archive. Explicit
// Unicode metadata always wins over the selected fallback encoding.
func DecodeNames(files []*zip.File, charset string) ([]string, error) {
if charset == "" {
charset = Inspect(files).Suggested
if charset == "" {
return nil, fmt.Errorf("%w: choose an encoding before extracting", ErrEncoding)
}
}
for _, c := range codecs {
if c.name != charset {
continue
}
names := make([]string, 0, len(files))
for _, f := range files {
name, err := decode(f, c)
if err != nil {
return nil, err
}
names = append(names, name)
}
return names, nil
}
return nil, fmt.Errorf("%w: unsupported encoding %s", ErrEncoding, charset)
}
@@ -1,119 +0,0 @@
package archiveencoding
import (
"archive/zip"
"encoding/binary"
"golang.org/x/text/encoding/japanese"
"hash/crc32"
"reflect"
"testing"
)
func entry(name string) *zip.File { return &zip.File{FileHeader: zip.FileHeader{Name: name}} }
func cp932(t *testing.T, name string) string {
t.Helper()
s, err := japanese.ShiftJIS.NewEncoder().String(name)
if err != nil {
t.Fatal(err)
}
return s
}
func TestJapaneseSuggestionMatchesDecoding(t *testing.T) {
t.Parallel()
want := []string{"日本語/テストソ001.txt", "日本語/テストソ002.txt"}
files := []*zip.File{entry(cp932(t, want[0])), entry(cp932(t, want[1]))}
preview := Inspect(files)
if preview.Suggested != "cp932" {
t.Fatalf("suggestion: %+v", preview)
}
names, err := DecodeNames(files, preview.Suggested)
if err != nil || !reflect.DeepEqual(names, want) {
t.Fatalf("names=%q err=%v", names, err)
}
for _, c := range preview.Candidates {
if c.Encoding == preview.Suggested && !reflect.DeepEqual(c.Names, names) {
t.Fatalf("preview mismatch: %q", c.Names)
}
}
}
func TestAmbiguousNamesRequireSelection(t *testing.T) {
t.Parallel()
files := []*zip.File{entry("caf\x82.txt")}
if p := Inspect(files); p.Suggested != "" {
t.Fatalf("ambiguous name was guessed: %+v", p)
}
if _, err := DecodeNames(files, ""); err == nil {
t.Fatal("expected explicit choice")
}
names, err := DecodeNames(files, "cp437")
if err != nil || names[0] != "café.txt" {
t.Fatalf("names=%q err=%v", names, err)
}
for _, c := range []string{"utf-8", "unknown"} {
if _, err = DecodeNames(files, c); err == nil {
t.Fatalf("invalid encoding accepted: %s", c)
}
}
}
func unicodeExtra(raw, name string) []byte {
payload := append([]byte{1, 0, 0, 0, 0}, []byte(name)...)
binary.LittleEndian.PutUint32(payload[1:], crc32.ChecksumIEEE([]byte(raw)))
extra := []byte{0x75, 0x70, 0, 0}
binary.LittleEndian.PutUint16(extra[2:], uint16(len(payload)))
return append(extra, payload...)
}
func TestUnicodeMetadataTakesPrecedence(t *testing.T) {
t.Parallel()
flagged := entry("日本語.txt")
flagged.Flags = 0x800
extra := entry("legacy.txt")
extra.Extra = unicodeExtra(extra.Name, "別の名前.txt")
names, err := DecodeNames([]*zip.File{flagged, extra}, "cp437")
if err != nil || !reflect.DeepEqual(names, []string{"日本語.txt", "別の名前.txt"}) {
t.Fatalf("names=%q err=%v", names, err)
}
extra.Extra[5] ^= 1
names, err = DecodeNames([]*zip.File{extra}, "cp437")
if err != nil || names[0] != "legacy.txt" {
t.Fatalf("names=%q err=%v", names, err)
}
}
func TestInvalidUnicodeMetadata(t *testing.T) {
t.Parallel()
for _, raw := range []string{"bad\xff.txt", "bad\x00.txt"} {
f := entry(raw)
f.Flags = 0x800
if _, err := DecodeNames([]*zip.File{f}, "cp932"); err == nil {
t.Fatalf("invalid declared UTF-8 accepted: %q", raw)
}
}
f := entry("ok.txt")
f.Extra = []byte{0x75, 0x70, 0xff, 0xff}
if _, err := DecodeNames([]*zip.File{f}, "utf-8"); err != nil {
t.Fatal(err)
}
}
func TestUTF8AndASCIIArchives(t *testing.T) {
t.Parallel()
for _, files := range [][]*zip.File{nil, {entry("readme.txt")}, {entry("日本語.txt")}} {
if p := Inspect(files); p.Suggested != "utf-8" {
t.Fatalf("expected UTF-8: %+v", p)
}
}
}
func TestPreviewBoundAndAllNamesValidated(t *testing.T) {
t.Parallel()
files := make([]*zip.File, 20)
for i := range files {
files[i] = entry("readme.txt")
}
files = append(files, entry("invalid\xff.txt"))
for _, c := range Inspect(files).Candidates {
if len(c.Names) > 5 {
t.Fatal("unbounded preview")
}
if c.Encoding == "utf-8" {
t.Fatal("entry beyond preview was not validated")
}
}
}
+8 -10
View File
@@ -6,10 +6,9 @@ import (
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// Auther is the authentication interface.
@@ -40,29 +39,28 @@ func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
if _, ok := user.Tokens[name]; ok {
return "", users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
}
id, err := utils.RandomHex(16)
if err != nil {
return "", users.AuthToken{}, fmt.Errorf("generate token id: %w", err)
}
now := time.Now()
expires := now.Add(duration)
// Create minimal token with only JWT standard claims
claim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
ID: id,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: FB_ISSUER,
},
},
}
claim.Minimal = &minimal
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 -1
View File
@@ -14,5 +14,5 @@ type JSONAuth struct {
// Auth authenticates the user via a json in content body (legacy method for compatibility).
func (auther JSONAuth) Auth(r *http.Request, _ *users.Storage) (*users.User, error) {
return AuthenticatePassword(r, auther.ReCaptcha, false)
return AuthenticatePassword(r, auther.ReCaptcha)
}
-24
View File
@@ -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()
}
}
-33
View File
@@ -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)
}
}
+3 -1
View File
@@ -63,7 +63,9 @@ func invalidSignatureSpelling(token string) string {
}
func parseSessionToken(tokenString string) error {
keyFunc := JWTSigningKeyFunc()
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(settings.Config.Auth.Key), nil
}
var tk users.AuthToken
_, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
return err
-12
View File
@@ -1,12 +0,0 @@
package auth
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
+4 -1
View File
@@ -15,7 +15,10 @@ type NoAuth struct{}
// AuthenticateNoAuth authenticates as the configured admin user with no credentials required.
func AuthenticateNoAuth(r *http.Request, user *users.Storage) (*users.User, error) {
admin := settings.PasswordAdminUsername()
admin := settings.Config.Auth.AdminUsername
if admin == "" {
admin = "admin"
}
id, err := users.ResolveUsernameToID(admin)
if err != nil {
return nil, err
-31
View File
@@ -1,10 +1,8 @@
package auth
import (
"encoding/json"
"net/http"
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
@@ -40,32 +38,3 @@ func AuthenticateProxy(r *http.Request, usr *users.Storage, headerName string) (
func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
return AuthenticateProxy(r, usr, a.Header)
}
// ExtractGroupsFromHeader reads group/role values from a proxy header named by groupsClaim.
// Returns present=false when the header name is empty or the header is missing/blank.
func ExtractGroupsFromHeader(r *http.Request, headerName string) ([]string, bool) {
if headerName == "" {
return nil, false
}
headerVal := strings.TrimSpace(r.Header.Get(headerName))
if headerVal == "" {
return nil, false
}
if strings.HasPrefix(headerVal, "[") {
var groups []string
if err := json.Unmarshal([]byte(headerVal), &groups); err == nil {
return groups, true
}
}
groups := ExtractGroupsFromClaims(map[string]interface{}{headerName: headerVal}, headerName)
if len(groups) == 1 && strings.Contains(groups[0], ",") {
parts := strings.Split(groups[0], ",")
groups = make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
groups = append(groups, part)
}
}
}
return groups, true
}
@@ -1,81 +0,0 @@
package auth_test
import (
"net/http"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
)
func TestExtractGroupsFromHeader_SingleValue(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Cosmos-Role", "2")
groups, present := auth.ExtractGroupsFromHeader(req, "x-cosmos-role")
if !present {
t.Fatal("expected groups to be present")
}
if len(groups) != 1 || groups[0] != "2" {
t.Fatalf("groups = %#v, want [\"2\"]", groups)
}
}
func TestExtractGroupsFromHeader_CommaSeparated(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Groups", "1, 2")
groups, present := auth.ExtractGroupsFromHeader(req, "X-Groups")
if !present {
t.Fatal("expected groups to be present")
}
if len(groups) != 2 || groups[0] != "1" || groups[1] != "2" {
t.Fatalf("groups = %#v, want [\"1\", \"2\"]", groups)
}
}
func TestExtractGroupsFromHeader_JSONArray(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Groups", `["admin","users"]`)
groups, present := auth.ExtractGroupsFromHeader(req, "X-Groups")
if !present {
t.Fatal("expected groups to be present")
}
if len(groups) != 2 || groups[0] != "admin" || groups[1] != "users" {
t.Fatalf("groups = %#v, want [\"admin\", \"users\"]", groups)
}
}
func TestExtractGroupsFromHeader_Missing(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
groups, present := auth.ExtractGroupsFromHeader(req, "X-Groups")
if present {
t.Fatalf("expected groups to be absent, got %#v", groups)
}
}
func TestExtractGroupsFromHeader_EmptyHeaderName(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Groups", "admin")
groups, present := auth.ExtractGroupsFromHeader(req, "")
if present {
t.Fatalf("expected groups to be absent, got %#v", groups)
}
}
+3 -7
View File
@@ -30,8 +30,7 @@ func SetDefault(s *Service) {
}
// AuthenticatePassword authenticates the user via password in request headers.
// When skipRequirePasswordChange is true, a valid password is accepted even if RequirePasswordChange is set (change-required flow).
func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool, skipRequirePasswordChange bool) (*users.User, error) {
func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users.User, error) {
if s == nil || s.users == nil {
return nil, fmt.Errorf("auth service not configured")
}
@@ -64,9 +63,6 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool, skipReq
if getErr != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
if user.RequirePasswordChange && user.LoginMethod == users.LoginMethodPassword && !skipRequirePasswordChange {
return nil, errors.ErrPasswordChangeRequired
}
if user.TOTPSecret != "" && !disableOtp {
if totpCode == "" {
return nil, errors.ErrNoTotpProvided
@@ -84,9 +80,9 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool, skipReq
}
// AuthenticatePassword authenticates via the default service.
func AuthenticatePassword(r *http.Request, disableOtp bool, skipRequirePasswordChange bool) (*users.User, error) {
func AuthenticatePassword(r *http.Request, disableOtp bool) (*users.User, error) {
if defaultService != nil {
return defaultService.AuthenticatePassword(r, disableOtp, skipRequirePasswordChange)
return defaultService.AuthenticatePassword(r, disableOtp)
}
return nil, fmt.Errorf("auth service not configured")
}
+14 -7
View File
@@ -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) {
@@ -1,56 +0,0 @@
package auth
import (
"testing"
"testing/synctest"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestSignedTokensAreUniqueAtSameTime(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-signing-key-for-token-uniqueness"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
// Freeze the clock so every token has identical issue and expiry times.
synctest.Test(t, func(t *testing.T) {
now := time.Now()
tokens := make(map[string]users.AuthToken)
for _, userID := range []uint64{1, 2} {
user := &users.User{ID: userID}
for _, minimal := range []bool{true, false} {
for range 2 {
tokenString, claim, err := MakeSignedTokenAPI(user, "test-token", time.Hour, users.Permissions{Api: true}, minimal)
if err != nil {
t.Fatal(err)
}
if _, exists := tokens[tokenString]; exists {
t.Fatalf("token collision for user %d with minimal=%v", userID, minimal)
}
tokens[tokenString] = claim
}
}
}
ids := make(map[string]bool)
for tokenString, claim := range tokens {
var parsed users.MinimalAuthToken
if _, err := jwt.ParseWithClaims(tokenString, &parsed, JWTSigningKeyFunc()); err != nil {
t.Fatalf("parse signed token: %v", err)
}
if parsed.ID == "" || ids[parsed.ID] {
t.Fatal("each signed token must have a unique nonempty id")
}
ids[parsed.ID] = true
if parsed.ID != claim.ID {
t.Fatal("signed token id must match returned metadata")
}
if parsed.IssuedAt == nil || !parsed.IssuedAt.Equal(now) || parsed.ExpiresAt == nil || !parsed.ExpiresAt.Equal(now.Add(time.Hour)) {
t.Fatal("token uniqueness must not depend on changing timestamps")
}
assertMinimalJWTClaims(t, tokenString)
}
})
}
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,6 @@
package access_test
import (
stderrors "errors"
"os"
"path/filepath"
"strings"
@@ -1852,200 +1851,3 @@ func TestAddUserToGroup_WriteThrough(t *testing.T) {
t.Fatal("bob should be removed from editors in SQL")
}
}
func TestSetGroupMembers_ReplacesMembership(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("editors", []string{"alice", "bob", ""}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.SetGroupMembers("editors", []string{"bob", "carol"}); err != nil {
t.Fatalf("SetGroupMembers replace: %v", err)
}
got := s.GetGroupMembers()["editors"]
if len(got) != 2 || got[0] != "bob" || got[1] != "carol" {
t.Fatalf("unexpected members: %v", got)
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatalf("GetAllGroups: %v", err)
}
if _, ok := sqlGroups["editors"]["alice"]; ok {
t.Fatal("alice should have been removed in SQL")
}
if _, ok := sqlGroups["editors"]["carol"]; !ok {
t.Fatal("carol should be persisted in SQL")
}
}
func TestDeleteGroup_RemovesGroupAndRules(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup: %v", err)
}
if err := s.DeleteGroup("acme"); err != nil {
t.Fatalf("DeleteGroup: %v", err)
}
if _, ok := s.GetGroupMembers()["acme"]; ok {
t.Fatal("acme should be gone from memory")
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatalf("GetAllGroups: %v", err)
}
if _, ok := sqlGroups["acme"]; ok {
t.Fatal("acme should be gone from SQL")
}
if rules := s.GetRulesForGroup("mnt/storage", "acme"); len(rules) != 0 {
t.Fatalf("expected no rules for deleted group, got %v", rules)
}
// Deleting a missing group is a no-op.
if err := s.DeleteGroup("acme"); err != nil {
t.Fatalf("DeleteGroup missing: %v", err)
}
}
// failingGroupDeletePersister wraps a real store but fails the transactional group delete.
type failingGroupDeletePersister struct {
access.SQLPersister
}
func (failingGroupDeletePersister) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
return stderrors.New("simulated sql failure")
}
func TestDeleteGroup_FailureLeavesGroupAndRulesIntact(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup: %v", err)
}
if err := s.DenyGroup("mnt/storage", idxPath("/private"), "acme"); err != nil {
t.Fatalf("DenyGroup: %v", err)
}
rulesBefore := len(s.GetRulesForGroup("mnt/storage", "acme"))
if rulesBefore != 2 {
t.Fatalf("expected 2 rules before delete, got %d", rulesBefore)
}
s.SetSQLStore(failingGroupDeletePersister{sqlStore})
if err := s.DeleteGroup("acme"); err == nil {
t.Fatal("expected DeleteGroup to return the SQL error")
}
if got := s.GetGroupMembers()["acme"]; len(got) != 1 || got[0] != "alice" {
t.Fatalf("group should be restored after a failed delete, got %v", got)
}
if got := len(s.GetRulesForGroup("mnt/storage", "acme")); got != rulesBefore {
t.Fatalf("rules should be restored after a failed delete: want %d, got %d", rulesBefore, got)
}
// SQL was never changed.
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["acme"]; !ok {
t.Fatal("group row should still be in SQL")
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
if len(sqlRules["mnt/storage"]) != 2 {
t.Fatalf("rule rows should still be in SQL, got %v", sqlRules["mnt/storage"])
}
}
func TestDeleteGroup_RemovesGroupAndRuleRowsFromSQL(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup: %v", err)
}
if err := s.AllowUser("mnt/storage", idxPath("/tenant"), "alice"); err != nil {
t.Fatalf("AllowUser: %v", err)
}
if err := s.DeleteGroup("acme"); err != nil {
t.Fatalf("DeleteGroup: %v", err)
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["acme"]; ok {
t.Fatal("group row should be gone from SQL")
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
for _, rule := range sqlRules["mnt/storage"] {
if _, has := rule.Allow.Groups["acme"]; has {
t.Fatal("no stored rule should still reference the deleted group")
}
}
// The rule that also allowed a user must survive with only the group removed.
if len(sqlRules["mnt/storage"]) != 1 {
t.Fatalf("expected the user rule to remain, got %v", sqlRules["mnt/storage"])
}
}
func TestDeleteGroup_KeepsDenyAllRule(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
// "Deny everyone except group acme": a DenyAll rule whose only allow entry is the group.
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.DenyAll("mnt/storage", idxPath("/tenant")); err != nil {
t.Fatalf("DenyAll: %v", err)
}
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup: %v", err)
}
if !s.Permitted("mnt/storage", idxPath("/tenant"), "alice") {
t.Fatal("alice should be permitted through the group before it is deleted")
}
if err := s.DeleteGroup("acme"); err != nil {
t.Fatalf("DeleteGroup: %v", err)
}
// Deleting the group must not remove the deny-all rule and open the path to everyone.
if s.Permitted("mnt/storage", idxPath("/tenant"), "alice") || s.Permitted("mnt/storage", idxPath("/tenant"), "bob") {
t.Fatal("path must stay denied for everyone after the allowed group is deleted")
}
rules := s.GetRulesForGroup("mnt/storage", "acme")
if len(rules) != 0 {
t.Fatalf("no rule should reference the deleted group, got %v", rules)
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
stored := 0
for _, r := range sqlRules["mnt/storage"] {
if r.DenyAll {
stored++
}
}
if stored != 1 {
t.Fatalf("expected the deny-all rule to remain stored in SQL, found %d", stored)
}
}
@@ -1,184 +0,0 @@
package access_test
import (
"errors"
"maps"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
type stubGroupSQL struct {
saveErr error
deleteErr error
}
func (s *stubGroupSQL) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
func (s *stubGroupSQL) DeleteAccessRule(string, string) error { return nil }
func (s *stubGroupSQL) AccessRulesLowercaseMigrated() (bool, error) { return false, nil }
func (s *stubGroupSQL) SetAccessRulesLowercaseMigrated() error { return nil }
func (s *stubGroupSQL) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
return nil
}
func (s *stubGroupSQL) SaveGroup(string, access.StringSet) error {
return s.saveErr
}
func (s *stubGroupSQL) DeleteGroup(string) error { return s.deleteErr }
func (s *stubGroupSQL) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
return s.deleteErr
}
func (s *stubGroupSQL) SaveRevokedToken(string, int64) error {
return nil
}
func (s *stubGroupSQL) PersistImmediateTokenRevocation(string) error { return nil }
func (s *stubGroupSQL) PersistTokenRetirement(string, int64, []string) error { return nil }
func (s *stubGroupSQL) DeleteRevokedToken(string) error { return nil }
func (s *stubGroupSQL) SaveHashedToken(string, uint64, bool, int64) error { return nil }
func (s *stubGroupSQL) DeleteHashedToken(string) error { return nil }
func (s *stubGroupSQL) DeleteHashedTokensByUserID(uint64) error {
return nil
}
type recordingGroupSQL struct {
groups map[string]access.StringSet
saveCalls int
failOn int
}
func (s *recordingGroupSQL) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
func (s *recordingGroupSQL) DeleteAccessRule(string, string) error { return nil }
func (s *recordingGroupSQL) AccessRulesLowercaseMigrated() (bool, error) { return false, nil }
func (s *recordingGroupSQL) SetAccessRulesLowercaseMigrated() error { return nil }
func (s *recordingGroupSQL) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
return nil
}
func (s *recordingGroupSQL) SaveGroup(name string, members access.StringSet) error {
if s.groups == nil {
s.groups = make(map[string]access.StringSet)
}
s.saveCalls++
if s.failOn > 0 && s.saveCalls == s.failOn {
return errors.New("save failed")
}
s.groups[name] = cloneStringSet(members)
return nil
}
func (s *recordingGroupSQL) DeleteGroup(name string) error {
if s.groups == nil {
s.groups = make(map[string]access.StringSet)
}
delete(s.groups, name)
return nil
}
func (s *recordingGroupSQL) DeleteGroupWithRules(name string, _ []access.RuleUpsert, _ []access.RuleKey) error {
return s.DeleteGroup(name)
}
func (s *recordingGroupSQL) SaveRevokedToken(string, int64) error { return nil }
func (s *recordingGroupSQL) PersistImmediateTokenRevocation(string) error { return nil }
func (s *recordingGroupSQL) PersistTokenRetirement(string, int64, []string) error { return nil }
func (s *recordingGroupSQL) DeleteRevokedToken(string) error { return nil }
func (s *recordingGroupSQL) SaveHashedToken(string, uint64, bool, int64) error { return nil }
func (s *recordingGroupSQL) DeleteHashedToken(string) error { return nil }
func (s *recordingGroupSQL) DeleteHashedTokensByUserID(uint64) error { return nil }
func cloneStringSet(src access.StringSet) access.StringSet {
if len(src) == 0 {
return nil
}
dst := make(access.StringSet, len(src))
maps.Copy(dst, src)
return dst
}
func TestAddUserToGroup_RollsBackOnSQLFailure(t *testing.T) {
userStore := users.NewStorage(nil)
store := access.NewStorage(userStore)
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
err := store.AddUserToGroup("editors", "alice")
if err == nil {
t.Fatal("expected SQL save error")
}
groups := store.GetUserGroups("alice")
if len(groups) != 0 {
t.Fatalf("expected in-memory rollback, alice groups = %#v", groups)
}
}
func TestRemoveUserFromGroup_RollsBackOnSQLFailure(t *testing.T) {
userStore := users.NewStorage(nil)
store := access.NewStorage(userStore)
if err := store.AddUserToGroup("editors", "alice"); err != nil {
t.Fatalf("seed group: %v", err)
}
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
err := store.RemoveUserFromGroup("editors", "alice")
if err == nil {
t.Fatal("expected SQL save error")
}
groups := store.GetUserGroups("alice")
if len(groups) != 1 || groups[0] != "editors" {
t.Fatalf("expected in-memory rollback, alice groups = %#v", groups)
}
}
func TestAllowGroup_ReturnsSQLSaveError(t *testing.T) {
setupTestSources()
store := access.NewStorage(users.NewStorage(nil))
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
err := store.AllowGroup("mnt/storage", idxPath("/tenant"), "acme")
if err == nil {
t.Fatal("expected SQL save error from AllowGroup")
}
}
func TestEnsureGroupExistsNL_RollsBackOnSQLFailure(t *testing.T) {
setupTestSources()
store := access.NewStorage(users.NewStorage(nil))
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
err := store.AllowGroup("mnt/storage", idxPath("/tenant"), "newgroup")
if err == nil {
t.Fatal("expected SQL save error")
}
if groups := store.GetAllGroups(); len(groups) != 0 {
t.Fatalf("expected no in-memory group after failed ensure, got %#v", groups)
}
}
func TestSyncUserGroups_RollsBackOnPartialPersistFailure(t *testing.T) {
userStore := users.NewStorage(nil)
store := access.NewStorage(userStore)
store.Groups["groupA"] = access.StringSet{"alice": {}}
rec := &recordingGroupSQL{
failOn: 2,
groups: map[string]access.StringSet{
"groupA": {"alice": {}},
},
}
store.SetSQLStore(rec)
err := store.SyncUserGroups("alice", []string{"groupB"})
if err == nil {
t.Fatal("expected partial persist failure")
}
groups := store.GetUserGroups("alice")
if len(groups) != 1 || groups[0] != "groupA" {
t.Fatalf("expected in-memory rollback to groupA only, got %#v", groups)
}
if _, ok := store.Groups["groupB"]; ok {
t.Fatal("groupB should not remain in memory after rollback")
}
if rec.saveCalls != 3 {
t.Fatalf("saveCalls = %d, want 3 (groupA persist, groupB fail, groupA restore)", rec.saveCalls)
}
if _, ok := rec.groups["groupA"]["alice"]; !ok {
t.Fatalf("expected SQL rollback for groupA, got %#v", rec.groups["groupA"])
}
if _, ok := rec.groups["groupB"]; ok {
t.Fatalf("expected SQL groupB to be absent after rollback, got %#v", rec.groups["groupB"])
}
}
@@ -1,79 +0,0 @@
package access
import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// MaxGroupNameLength bounds names for manually created groups. Existing names
// (e.g. LDAP DNs synced from an IdP) may exceed it and are never rejected on read.
const MaxGroupNameLength = 128
// MinGroupNameLength bounds manually created group names. Existing records are
// never rejected so legacy single-character names stay usable.
const MinGroupNameLength = 2
// NormalizeGroupName trims and validates a group name supplied by an admin API.
// It is only used when manually creating or referencing a group; names that
// already exist in the database are always accepted on read, list, edit and
// delete paths so legacy records stay usable.
func NormalizeGroupName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("group name is required")
}
if utf8.RuneCountInString(name) < MinGroupNameLength && !isSingleLetterName(name) {
return "", fmt.Errorf("group name must be at least %d characters", MinGroupNameLength)
}
if utf8.RuneCountInString(name) > MaxGroupNameLength {
return "", fmt.Errorf("group name exceeds %d characters", MaxGroupNameLength)
}
for _, r := range name {
if unicode.IsControl(r) {
return "", fmt.Errorf("group name must not contain control characters")
}
}
return name, nil
}
// isSingleLetterName reports whether name is one non-ASCII letter (e.g. a CJK ideograph),
// which is a meaningful group name on its own.
func isSingleLetterName(name string) bool {
r, size := utf8.DecodeRuneInString(name)
return size == len(name) && r > unicode.MaxASCII && unicode.IsLetter(r)
}
// NormalizeMembers trims, dedupes and drops empty usernames while preserving order.
func NormalizeMembers(usernames []string) []string {
seen := make(map[string]struct{}, len(usernames))
out := make([]string, 0, len(usernames))
for _, name := range usernames {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
return out
}
// IsUsableSyncedGroupName reports whether a group name coming from an identity
// provider can be stored. Only the minimum needed to keep rows usable is
// rejected; everything else is kept so IdP naming conventions keep working.
func IsUsableSyncedGroupName(name string) bool {
if strings.TrimSpace(name) == "" {
return false
}
for _, r := range name {
if unicode.IsControl(r) {
return false
}
}
return true
}
@@ -1,115 +0,0 @@
package access_test
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
)
func TestNormalizeGroupName(t *testing.T) {
for _, tc := range []struct {
name string
in string
want string
wantErr bool
}{
{"trims", " editors ", "editors", false},
{"spaces inside", "cn=admins with spaces,ou=x", "cn=admins with spaces,ou=x", false},
{"empty", "", "", true},
{"blank", " ", "", true},
{"newline", "a\nb", "", true},
{"too short", "a", "", true},
{"single wide char", "界", "界", false},
{"two chars", "ab", "ab", false},
{"single CJK char", "组", "组", false},
{"single non-letter non-ascii", "€", "", true},
{"too long", string(make([]byte, access.MaxGroupNameLength+1)), "", true},
} {
got, err := access.NormalizeGroupName(tc.in)
if tc.wantErr {
if err == nil {
t.Fatalf("%s: expected error, got %q", tc.name, got)
}
continue
}
if err != nil || got != tc.want {
t.Fatalf("%s: got %q, err=%v", tc.name, got, err)
}
}
// Long names of valid characters fail on length, not on content.
if _, err := access.NormalizeGroupName(string(make([]byte, access.MaxGroupNameLength+1))); err == nil {
t.Fatal("expected too-long name to fail")
}
if got := access.NormalizeMembers([]string{" a ", "b", "", "a", " "}); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected members: %v", got)
}
}
func TestRemoveUserFromAllGroups(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("editors", []string{"alice", "bob"}); err != nil {
t.Fatal(err)
}
if err := s.SetGroupMembers("staff", []string{"alice"}); err != nil {
t.Fatal(err)
}
if err := s.RemoveUserFromAllGroups("alice"); err != nil {
t.Fatal(err)
}
if groups := s.GetUserGroups("alice"); len(groups) != 0 {
t.Fatalf("alice should be in no groups, got %v", groups)
}
if got := s.GetGroupMembers()["editors"]; len(got) != 1 || got[0] != "bob" {
t.Fatalf("expected [bob], got %v", got)
}
// The emptied group row still exists.
if _, ok := s.GetGroupMembers()["staff"]; !ok {
t.Fatal("empty group should remain")
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["staff"]["alice"]; ok {
t.Fatal("alice should be removed from staff in SQL")
}
// Removing a user who is in no group is a no-op.
if err := s.RemoveUserFromAllGroups("nobody"); err != nil {
t.Fatal(err)
}
}
func TestRenameUserInGroups(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("editors", []string{"alice", "bob"}); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInGroups("alice", "alicia"); err != nil {
t.Fatal(err)
}
got := s.GetGroupMembers()["editors"]
if len(got) != 2 || got[0] != "alicia" || got[1] != "bob" {
t.Fatalf("unexpected members: %v", got)
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["editors"]["alicia"]; !ok {
t.Fatal("alicia should be persisted in SQL")
}
if _, ok := sqlGroups["editors"]["alice"]; ok {
t.Fatal("alice should be removed in SQL")
}
// Renaming a non-member or same name is a no-op.
if err := s.RenameUserInGroups("nobody", "other"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInGroups("alicia", "alicia"); err != nil {
t.Fatal(err)
}
}
@@ -1,12 +0,0 @@
package access
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
@@ -1,192 +0,0 @@
package access
import (
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// Access rules are stored and matched using lowercase path keys (strings.ToLower).
// checkRulePathExists reports whether rulePath exists under sourcePath, matching
// path segments case-insensitively when the exact path is not found on case-sensitive filesystems.
func checkRulePathExists(sourcePath, rulePath string) bool {
if utils.CheckPathExists(filepath.Join(sourcePath, rulePath)) {
return true
}
rel := strings.Trim(strings.TrimSpace(rulePath), "/")
if rel == "" {
return utils.CheckPathExists(sourcePath)
}
current := sourcePath
for _, part := range strings.Split(rel, "/") {
if part == "" {
continue
}
candidate := filepath.Join(current, part)
if utils.CheckPathExists(candidate) {
current = candidate
continue
}
entries, err := os.ReadDir(current)
if err != nil {
return false
}
partLower := strings.ToLower(part)
matched := ""
for _, e := range entries {
if strings.ToLower(e.Name()) == partLower {
matched = e.Name()
break
}
}
if matched == "" {
return false
}
current = filepath.Join(current, matched)
}
return true
}
// ruleKeyForNL returns the storage key; caller must hold s.mux.
func (s *Storage) ruleKeyForNL(_ string, indexPath utils.IndexPath) string {
return strings.ToLower(ruleKey(indexPath))
}
// ruleKeyFor returns the storage/lookup key for a rule path on this source.
func (s *Storage) ruleKeyFor(sourcePath string, indexPath utils.IndexPath) string {
s.mux.RLock()
defer s.mux.RUnlock()
return s.ruleKeyForNL(sourcePath, indexPath)
}
func (s *Storage) checkPathForSourceNL(_ string, indexPath utils.IndexPath) utils.IndexPath {
p := checkPath(indexPath)
return utils.IndexPathFromNormalized(strings.ToLower(p.String()), p.IsDir)
}
// checkPathForSource normalizes an index path for permission checks on a source.
func (s *Storage) checkPathForSource(sourcePath string, indexPath utils.IndexPath) utils.IndexPath {
s.mux.RLock()
defer s.mux.RUnlock()
return s.checkPathForSourceNL(sourcePath, indexPath)
}
// StartupMigrateAccessRuleKeys lowercases existing rule keys in memory and SQL,
// merging colliding paths. Idempotent when keys are already lowercase.
func (s *Storage) StartupMigrateAccessRuleKeys() error {
s.mux.Lock()
defer s.mux.Unlock()
for sourcePath := range s.AllRules {
if err := s.migrateSourceRulesLowercaseNL(sourcePath); err != nil {
return err
}
}
if s.sqlStore == nil {
return nil
}
migrated, err := s.sqlStore.AccessRulesLowercaseMigrated()
if err != nil {
return err
}
if migrated {
return nil
}
return s.sqlStore.SetAccessRulesLowercaseMigrated()
}
func (s *Storage) migrateSourceRulesLowercaseNL(sourcePath string) error {
rulesBySource, ok := s.AllRules[sourcePath]
if !ok || len(rulesBySource) == 0 {
return nil
}
if !rulesNeedLowercaseMigration(rulesBySource) {
return nil
}
oldPaths := make([]string, 0, len(rulesBySource))
newRules := make(RuleMap)
for path, rule := range rulesBySource {
oldPaths = append(oldPaths, path)
lower := strings.ToLower(path)
if existing, exists := newRules[lower]; exists {
logger.Infof("access rules: merging colliding paths %q into %q on source %s", path, lower, sourcePath)
mergeAccessRulesInPlace(existing, rule)
continue
}
fresh := &AccessRule{}
mergeAccessRulesInPlace(fresh, rule)
newRules[lower] = fresh
if lower != path {
logger.Infof("access rules: migrated rule path %q -> %q on source %s", path, lower, sourcePath)
}
}
s.AllRules[sourcePath] = newRules
if s.sqlStore == nil {
return nil
}
return s.sqlStore.MigrateAccessRulePathsLowercase(sourcePath, oldPaths, newRules)
}
func rulesNeedLowercaseMigration(rules RuleMap) bool {
byLower := make(map[string]string)
for path := range rules {
lower := strings.ToLower(path)
if path != lower {
return true
}
if other, ok := byLower[lower]; ok && other != path {
return true
}
byLower[lower] = path
}
return false
}
func mergeAccessRulesInPlace(dst, src *AccessRule) {
if src == nil || dst == nil {
return
}
if src.DenyAll {
dst.DenyAll = true
}
if dst.Deny.Users == nil {
dst.Deny.Users = StringSet{}
}
if dst.Deny.Groups == nil {
dst.Deny.Groups = StringSet{}
}
if dst.Allow.Users == nil {
dst.Allow.Users = StringSet{}
}
if dst.Allow.Groups == nil {
dst.Allow.Groups = StringSet{}
}
for u := range src.Deny.Users {
dst.Deny.Users[u] = struct{}{}
}
for g := range src.Deny.Groups {
dst.Deny.Groups[g] = struct{}{}
}
for u := range src.Allow.Users {
dst.Allow.Users[u] = struct{}{}
}
for g := range src.Allow.Groups {
dst.Allow.Groups[g] = struct{}{}
}
}
func (s *Storage) deleteStoredRuleNL(sourcePath, rulePath string) {
delete(s.AllRules[sourcePath], rulePath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
@@ -1,67 +0,0 @@
package access_test
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
)
func TestStartupMigrationMergesCollidingRules(t *testing.T) {
store, userStore, sqlStore := createTestStorageWithSQL(t)
_ = store
_ = userStore
ruleA := &access.AccessRule{
Deny: access.RuleSet{Users: map[string]struct{}{"alice": {}}},
}
ruleB := &access.AccessRule{
Deny: access.RuleSet{Users: map[string]struct{}{"bob": {}}},
}
if err := sqlStore.SaveAccessRule("/src", "/Private/", ruleA); err != nil {
t.Fatal(err)
}
if err := sqlStore.SaveAccessRule("/src", "/private/", ruleB); err != nil {
t.Fatal(err)
}
s := access.NewStorage(nil)
rules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
s.AllRules = rules
s.SetSQLStore(sqlStore)
if startErr := s.StartupMigrateAccessRuleKeys(); startErr != nil {
t.Fatal(startErr)
}
byPath, err := sqlStore.GetAccessRulesBySource("/src")
if err != nil {
t.Fatal(err)
}
if len(byPath) != 1 {
t.Fatalf("expected one merged rule row, got %d", len(byPath))
}
merged := byPath["/private/"]
if merged == nil {
t.Fatal("expected merged rule at lowercase key")
}
if _, ok := merged.Deny.Users["alice"]; !ok {
t.Fatal("missing alice from merge")
}
if _, ok := merged.Deny.Users["bob"]; !ok {
t.Fatal("missing bob from merge")
}
migrated, err := sqlStore.AccessRulesLowercaseMigrated()
if err != nil || !migrated {
t.Fatal("marker should be set after first migration")
}
byPath2, err := sqlStore.GetAccessRulesBySource("/src")
if err != nil {
t.Fatal(err)
}
if len(byPath2) != 1 {
t.Fatal("rules should remain after marker is set without re-running migration")
}
}
@@ -1,152 +0,0 @@
package access_test
import (
"errors"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
)
type migrateCallCounter struct {
access.SQLPersister
calls int
}
func (m *migrateCallCounter) MigrateAccessRulePathsLowercase(source string, oldPaths []string, newRules access.RuleMap) error {
m.calls++
return m.SQLPersister.MigrateAccessRulePathsLowercase(source, oldPaths, newRules)
}
type failAccessRuleMigratePersister struct {
access.SQLPersister
}
func (failAccessRuleMigratePersister) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
return errors.New("simulated migration failure")
}
func TestFreshDBSetsAccessRulesLowercaseMarker(t *testing.T) {
s, _, sqlStore := createTestStorageWithSQL(t)
s.SetSQLStore(sqlStore)
if err := s.StartupMigrateAccessRuleKeys(); err != nil {
t.Fatal(err)
}
ok, err := sqlStore.AccessRulesLowercaseMigrated()
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected migration marker on empty database")
}
}
func TestUpgradeMigratesAndSetsMarker(t *testing.T) {
_, _, sqlStore := createTestStorageWithSQL(t)
rule := &access.AccessRule{
Deny: access.RuleSet{Users: map[string]struct{}{"alice": {}}},
}
if err := sqlStore.SaveAccessRule("/src", "/Private/", rule); err != nil {
t.Fatal(err)
}
s := access.NewStorage(nil)
rules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
s.AllRules = rules
counter := &migrateCallCounter{SQLPersister: sqlStore}
s.SetSQLStore(counter)
if err = s.StartupMigrateAccessRuleKeys(); err != nil {
t.Fatal(err)
}
if counter.calls != 1 {
t.Fatalf("expected one SQL migration, got %d", counter.calls)
}
ok, err := sqlStore.AccessRulesLowercaseMigrated()
if err != nil || !ok {
t.Fatalf("marker should be set after upgrade, ok=%v err=%v", ok, err)
}
byPath, err := sqlStore.GetAccessRulesBySource("/src")
if err != nil {
t.Fatal(err)
}
if byPath["/private/"] == nil {
t.Fatal("expected lowercased rule row")
}
}
func TestSecondStartupSkipsAccessRulesMigration(t *testing.T) {
_, _, sqlStore := createTestStorageWithSQL(t)
if err := sqlStore.SaveAccessRule("/src", "/Private/", &access.AccessRule{
Deny: access.RuleSet{Users: map[string]struct{}{"u": {}}},
}); err != nil {
t.Fatal(err)
}
s := access.NewStorage(nil)
rules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
s.AllRules = rules
counter := &migrateCallCounter{SQLPersister: sqlStore}
s.SetSQLStore(counter)
if err = s.StartupMigrateAccessRuleKeys(); err != nil {
t.Fatal(err)
}
if counter.calls != 1 {
t.Fatalf("first startup: expected 1 migrate call, got %d", counter.calls)
}
already, err := sqlStore.AccessRulesLowercaseMigrated()
if err != nil || !already {
t.Fatal("marker should be set after first startup")
}
s2 := access.NewStorage(nil)
rules2, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
s2.AllRules = rules2
s2.SetSQLStore(counter)
if err = s2.StartupMigrateAccessRuleKeys(); err != nil {
t.Fatal(err)
}
if counter.calls != 1 {
t.Fatalf("second startup must not invoke SQL migration, got %d calls", counter.calls)
}
}
func TestMigrationFailureDoesNotSetMarker(t *testing.T) {
_, _, sqlStore := createTestStorageWithSQL(t)
if err := sqlStore.SaveAccessRule("/src", "/Private/", &access.AccessRule{
Deny: access.RuleSet{Users: map[string]struct{}{"u": {}}},
}); err != nil {
t.Fatal(err)
}
s := access.NewStorage(nil)
rules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
s.AllRules = rules
s.SetSQLStore(failAccessRuleMigratePersister{SQLPersister: sqlStore})
if err = s.StartupMigrateAccessRuleKeys(); err == nil {
t.Fatal("expected migration failure")
}
ok, err := sqlStore.AccessRulesLowercaseMigrated()
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("marker must not be set when migration fails")
}
}
@@ -1,211 +0,0 @@
package access
import (
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestPermittedDenyCaseVariant(t *testing.T) {
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
if err := s.DenyUser("/data", utils.IndexPathFromNormalized("/private/secret.txt", false), "alice"); err != nil {
t.Fatal(err)
}
key := s.ruleKeyFor("/data", utils.IndexPathFromNormalized("/private/secret.txt", false))
if key != "/private/secret.txt/" {
t.Fatalf("stored key %q", key)
}
if s.Permitted("/data", utils.IndexPathFromNormalized("/PRIVATE/secret.txt", false), "alice") {
t.Fatal("case variant must hit deny rule")
}
}
func TestRemoveRuleByPathKeyCaseVariant(t *testing.T) {
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
if err := s.DenyUser("/data", utils.IndexPathFromNormalized("/private/x", false), "alice"); err != nil {
t.Fatal(err)
}
s.RemoveRuleByPathKey("/data", "/PRIVATE/x/")
if !s.Permitted("/data", utils.IndexPathFromNormalized("/private/x", false), "alice") {
t.Fatal("rule should be removed when key casing differs")
}
}
func TestPermittedHotPathNoFilesystemAccess(t *testing.T) {
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
_ = s.DenyUser("/data", utils.IndexPathFromNormalized("/a/b", false), "u")
if !s.Permitted("/data", utils.IndexPathFromNormalized("/A/B", false), "other") {
t.Fatal("expected permitted for non-denied user")
}
}
func BenchmarkPermittedLowercaseNoFS(b *testing.B) {
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
_ = s.DenyUser("/data", utils.IndexPathFromNormalized("/x/y", false), "u")
path := utils.IndexPathFromNormalized("/X/Y", false)
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.Permitted("/data", path, "bob")
}
}
func TestRulesNeedLowercaseMigration(t *testing.T) {
if rulesNeedLowercaseMigration(RuleMap{"/ok/": {}}) {
t.Fatal("already lowercase")
}
if !rulesNeedLowercaseMigration(RuleMap{"/Private/": {}}) {
t.Fatal("uppercase segment should need migration")
}
if !rulesNeedLowercaseMigration(RuleMap{"/Private/": {}, "/private/": {}}) {
t.Fatal("case collision should need migration")
}
}
func TestCheckRulePathExistsMixedCase(t *testing.T) {
root := t.TempDir()
mixedDir := filepath.Join(root, "MyFolder", "SubDir")
if err := os.MkdirAll(mixedDir, 0o755); err != nil {
t.Fatal(err)
}
mixedFile := filepath.Join(mixedDir, "Readme.MD")
if err := os.WriteFile(mixedFile, []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
root: {Path: root, Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
if err := s.DenyUser(root, utils.IndexPathFromNormalized("/MyFolder/SubDir/Readme.MD", false), "alice"); err != nil {
t.Fatal(err)
}
all, err := s.GetAllRules(root)
if err != nil {
t.Fatal(err)
}
rule, ok := all["/myfolder/subdir/readme.md/"]
if !ok {
t.Fatalf("expected lowercased rule key in GetAllRules, got %v", all)
}
if !rule.PathExists {
t.Fatal("expected PathExists=true for mixed-case path on disk")
}
feRule, ok := s.GetFrontendRules(root, utils.IndexPathFromNormalized("/myfolder/subdir/readme.md", false))
if !ok {
t.Fatal("expected rule from GetFrontendRules with lowercase query")
}
if !feRule.PathExists {
t.Fatal("expected PathExists=true in GetFrontendRules")
}
if checkRulePathExists(root, "/myfolder/subdir/nonexistent.txt/") {
t.Fatal("expected false for missing path")
}
}
func TestUpdateRulesAndUpdateRulePathCaseOnlyAndCollision(t *testing.T) {
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
if err := s.DenyUser("/data", utils.IndexPathFromNormalized("/MyFolder", true), "alice"); err != nil {
t.Fatal(err)
}
// Case-only rename is a no-op and preserves the rule.
updated, err := s.UpdateRules("/data", utils.IndexPathFromNormalized("/MyFolder", true), utils.IndexPathFromNormalized("/myfolder", true))
if err != nil {
t.Fatal(err)
}
if updated != 0 {
t.Fatalf("expected 0 updated for case-only rename, got %d", updated)
}
if s.Permitted("/data", utils.IndexPathFromNormalized("/MyFolder", true), "alice") {
t.Fatal("rule should still deny alice after case-only rename")
}
if err := s.UpdateRulePath("/data", utils.IndexPathFromNormalized("/MyFolder", true), utils.IndexPathFromNormalized("/myfolder", true)); err != nil {
t.Fatal(err)
}
if s.Permitted("/data", utils.IndexPathFromNormalized("/myfolder", true), "alice") {
t.Fatal("rule should still deny alice after case-only UpdateRulePath")
}
// Colliding target rule merges entries instead of overwriting.
if err := s.DenyUser("/data", utils.IndexPathFromNormalized("/Archive", true), "bob"); err != nil {
t.Fatal(err)
}
if err := s.UpdateRulePath("/data", utils.IndexPathFromNormalized("/MyFolder", true), utils.IndexPathFromNormalized("/archive", true)); err != nil {
t.Fatal(err)
}
if s.Permitted("/data", utils.IndexPathFromNormalized("/archive", true), "alice") ||
s.Permitted("/data", utils.IndexPathFromNormalized("/archive", true), "bob") {
t.Fatal("expected merged rule at /archive/ to deny both alice and bob")
}
}
func TestRemoveAllowUserPreservesDenyAll(t *testing.T) {
settings.Config = settings.Settings{
Server: settings.Server{
SourceMap: map[string]*settings.Source{
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
},
},
}
s := NewStorage(nil)
p := utils.IndexPathFromNormalized("/Secret", true)
if err := s.DenyAll("/data", p); err != nil {
t.Fatal(err)
}
if err := s.AllowUser("/data", p, "alice"); err != nil {
t.Fatal(err)
}
removed, err := s.RemoveAllowUser("/data", p, "alice")
if err != nil || !removed {
t.Fatalf("RemoveAllowUser failed: removed=%v err=%v", removed, err)
}
if s.Permitted("/data", p, "bob") {
t.Fatal("DenyAll must remain active after removing the last allowed user")
}
}
@@ -1,160 +0,0 @@
package access_test
import (
"errors"
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"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) AccessRulesLowercaseMigrated() (bool, error) { return false, nil }
func (failingRevokePersister) SetAccessRulesLowercaseMigrated() error { return nil }
func (failingRevokePersister) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
return nil
}
func (failingRevokePersister) SaveGroup(string, access.StringSet) error { return nil }
func (failingRevokePersister) DeleteGroup(string) error { return nil }
func (failingRevokePersister) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) 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, int64) 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")
}
}
// signedTestJWT mints a bare HS256 JWT carrying only an exp claim. The registry
// only reads claims unverified, so any key works.
func signedTestJWT(t *testing.T, exp time.Time) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"exp": exp.Unix()})
tokenString, err := token.SignedString([]byte("test-key"))
if err != nil {
t.Fatal(err)
}
return tokenString
}
func TestExpiredTokenResolvesWithinGrace(t *testing.T) {
store, _ := createTestStorage(t)
token := signedTestJWT(t, time.Now().Add(-time.Minute))
if err := store.AddSessionToken(token, 7); err != nil {
t.Fatal(err)
}
info, ok := store.GetHashedTokenInfo(token)
if !ok || info.UserID != 7 || !info.IsSession {
t.Fatalf("recently expired token must still resolve within grace, got %+v ok=%v", info, ok)
}
if info.ExpiresAt == 0 {
t.Fatal("expiry must be recorded from the token's exp claim")
}
}
func TestExpiredTokenPastGraceNeverRegistered(t *testing.T) {
store, _ := createTestStorage(t)
token := signedTestJWT(t, time.Now().Add(-access.BearerTokenGrace-time.Minute))
if err := store.AddSessionToken(token, 7); err != nil {
t.Fatal(err)
}
if _, ok := store.GetHashedTokenInfo(token); ok {
t.Fatal("token expired past grace must not resolve")
}
if _, ok := store.HashedTokens[utils.HashSHA256(token)]; ok {
t.Fatal("token expired past grace must not be registered")
}
}
@@ -1,129 +0,0 @@
package access_test
import (
"slices"
"testing"
)
func TestRenameUserInRules(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
src := "test_source"
if err := s.AllowUser(src, idxPath("/allowed"), "alice"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/denied"), "alice"); err != nil {
t.Fatal(err)
}
if err := s.AllowUser(src, idxPath("/both"), "alice"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/both"), "alice"); err != nil {
t.Fatal(err)
}
// New name already listed on another path (set semantics: old entry removed, new unchanged).
if err := s.AllowUser(src, idxPath("/existing"), "alicia"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInRules("alice", "alicia"); err != nil {
t.Fatal(err)
}
if rules := s.GetRulesForUser(src, "alice"); len(rules) != 0 {
t.Fatalf("expected no rules for old name alice, got %d", len(rules))
}
aliciaRules := s.GetRulesForUser(src, "alicia")
if len(aliciaRules) != 4 {
t.Fatalf("expected 4 paths for alicia, got %d", len(aliciaRules))
}
for _, path := range []string{"/allowed/", "/denied/", "/both/", "/existing/"} {
if _, ok := aliciaRules[path]; !ok {
t.Fatalf("missing rule at %q: %v", path, aliciaRules)
}
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
for _, rule := range sqlRules[src] {
if _, ok := rule.Allow.Users["alice"]; ok {
t.Fatal("alice should not appear in allow lists in SQL")
}
if _, ok := rule.Deny.Users["alice"]; ok {
t.Fatal("alice should not appear in deny lists in SQL")
}
}
if err := s.RenameUserInRules("nobody", "other"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInRules("alicia", "alicia"); err != nil {
t.Fatal(err)
}
}
func TestRemoveAllRulesForUserOnDelete(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
src := "test_source"
if err := s.AllowUser(src, idxPath("/a"), "bob"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/b"), "bob"); err != nil {
t.Fatal(err)
}
if err := s.RemoveAllRulesForUser("bob"); err != nil {
t.Fatal(err)
}
if rules := s.GetRulesForUser(src, "bob"); len(rules) != 0 {
t.Fatalf("expected no in-memory rules for bob, got %d", len(rules))
}
all, err := s.GetAllRules(src)
if err != nil {
t.Fatal(err)
}
if len(all) != 0 {
t.Fatalf("expected no rules left on source, got %d", len(all))
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
if len(sqlRules[src]) != 0 {
t.Fatalf("expected no SQL rules for source, got %v", sqlRules[src])
}
}
func TestRenameUserInRulesBothAllowAndDenySamePath(t *testing.T) {
setupTestSources()
s, _, _ := createTestStorageWithSQL(t)
src := "test_source"
if err := s.AllowUser(src, idxPath("/shared"), "old"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/shared"), "old"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInRules("old", "new"); err != nil {
t.Fatal(err)
}
rules, ok := s.GetFrontendRules(src, idxPath("/shared"))
if !ok {
t.Fatal("expected rule at /shared")
}
if !slices.Contains(rules.Allow.Users, "new") || slices.Contains(rules.Allow.Users, "old") {
t.Fatalf("allow users: %v", rules.Allow.Users)
}
if !slices.Contains(rules.Deny.Users, "new") || slices.Contains(rules.Deny.Users, "old") {
t.Fatalf("deny users: %v", rules.Deny.Users)
}
}
@@ -6,35 +6,31 @@ import "fmt"
type EventType string
const (
EventDownload EventType = "download"
EventMove EventType = "move"
EventCopy EventType = "copy"
EventRename EventType = "rename"
EventUpload EventType = "upload"
EventFileModify EventType = "fileModify"
EventDelete EventType = "delete"
EventBulkDelete EventType = "bulkDelete"
EventArchive EventType = "archive"
EventUnarchive EventType = "unarchive"
EventShareCreate EventType = "shareCreate"
EventShareUpdate EventType = "shareUpdate"
EventShareDelete EventType = "shareDelete"
EventUserCreate EventType = "userCreate"
EventUserUpdate EventType = "userUpdate"
EventUserDelete EventType = "userDelete"
EventAccessUpdate EventType = "accessUpdate"
EventAccessCreate EventType = "accessCreate"
EventAccessDelete EventType = "accessDelete"
EventQuotaCreate EventType = "quotaCreate"
EventQuotaUpdate EventType = "quotaUpdate"
EventQuotaDelete EventType = "quotaDelete"
EventLogin EventType = "login"
EventLogout EventType = "logout"
EventSignup EventType = "signup"
EventPasskeyRegister EventType = "passkeyRegister"
EventPasskeyDelete EventType = "passkeyDelete"
EventTokenCreate EventType = "tokenCreate"
EventTokenDelete EventType = "tokenDelete"
EventDownload EventType = "download"
EventMove EventType = "move"
EventCopy EventType = "copy"
EventRename EventType = "rename"
EventUpload EventType = "upload"
EventDelete EventType = "delete"
EventBulkDelete EventType = "bulkDelete"
EventArchive EventType = "archive"
EventUnarchive EventType = "unarchive"
EventShareCreate EventType = "shareCreate"
EventShareUpdate EventType = "shareUpdate"
EventShareDelete EventType = "shareDelete"
EventUserCreate EventType = "userCreate"
EventUserUpdate EventType = "userUpdate"
EventUserDelete EventType = "userDelete"
EventAccessUpdate EventType = "accessUpdate"
EventAccessCreate EventType = "accessCreate"
EventAccessDelete EventType = "accessDelete"
EventLogin EventType = "login"
EventLogout EventType = "logout"
EventSignup EventType = "signup"
EventPasskeyRegister EventType = "passkeyRegister"
EventPasskeyDelete EventType = "passkeyDelete"
EventTokenCreate EventType = "tokenCreate"
EventTokenDelete EventType = "tokenDelete"
EventDuplicateFinder EventType = "duplicateFinder"
)
@@ -45,7 +41,6 @@ var AllEventTypes = []EventType{
EventCopy,
EventRename,
EventUpload,
EventFileModify,
EventDelete,
EventBulkDelete,
EventArchive,
@@ -59,9 +54,6 @@ var AllEventTypes = []EventType{
EventAccessUpdate,
EventAccessCreate,
EventAccessDelete,
EventQuotaCreate,
EventQuotaUpdate,
EventQuotaDelete,
EventLogin,
EventLogout,
EventSignup,
@@ -79,7 +71,6 @@ var FileEventTypes = []EventType{
EventCopy,
EventRename,
EventUpload,
EventFileModify,
EventDelete,
EventBulkDelete,
EventArchive,
@@ -93,13 +84,6 @@ var AccessEventTypes = []EventType{
EventAccessDelete,
}
// QuotaEventTypes are folder quota mutations (scope=quotas).
var QuotaEventTypes = []EventType{
EventQuotaCreate,
EventQuotaUpdate,
EventQuotaDelete,
}
// ShareEventTypes are share lifecycle events (scope=shares). Share downloads are
// EventDownload rows with a non-empty details.shareHash.
var ShareEventTypes = []EventType{
@@ -124,15 +108,13 @@ func ResolveScopeEventTypes(scope string, explicit []EventType) ([]EventType, er
allowed = FileEventTypes
case "access":
allowed = AccessEventTypes
case "quotas":
allowed = QuotaEventTypes
case "shares":
if len(explicit) == 0 {
return nil, nil
}
allowed = ShareScopeEventTypes
default:
return nil, fmt.Errorf("scope must be all, files, access, quotas, or shares")
return nil, fmt.Errorf("scope must be all, files, access, or shares")
}
if len(explicit) == 0 {
return allowed, nil
@@ -158,11 +140,10 @@ func intersectEventTypes(requested, allowed []EventType) []EventType {
func (e EventType) Valid() bool {
switch e {
case EventDownload, EventMove, EventCopy, EventRename,
EventUpload, EventFileModify, EventDelete, EventBulkDelete,
EventUpload, EventDelete, EventBulkDelete,
EventArchive, EventUnarchive,
EventShareCreate, EventShareUpdate, EventShareDelete,
EventUserCreate, EventUserUpdate, EventUserDelete, EventAccessUpdate, EventAccessCreate, EventAccessDelete,
EventQuotaCreate, EventQuotaUpdate, EventQuotaDelete,
EventLogin, EventLogout, EventSignup,
EventPasskeyRegister, EventPasskeyDelete,
EventTokenCreate, EventTokenDelete,
@@ -44,18 +44,6 @@ func TestResolveScopeEventTypes(t *testing.T) {
t.Fatalf("access default missing %q in %v", et, got)
}
}
got, err = ResolveScopeEventTypes("quotas", nil)
if err != nil {
t.Fatal(err)
}
if len(got) != len(QuotaEventTypes) {
t.Fatalf("quotas default: got %d types, want %d", len(got), len(QuotaEventTypes))
}
for _, et := range QuotaEventTypes {
if !containsEventType(got, et) {
t.Fatalf("quotas default missing %q in %v", et, got)
}
}
}
func containsEventType(list []EventType, target EventType) bool {
@@ -21,9 +21,6 @@ type IndexInfo struct {
UsedAsIndexed uint64 `json:"used"`
UsedDisk uint64 `json:"usedAlt"`
DiskTotal uint64 `json:"total"`
// Root-filesystem-only view (no nested mount aggregation)
UsedDiskRoot uint64 `json:"usedAltRoot"`
DiskTotalRoot uint64 `json:"totalRoot"`
// Scanner information - map of scanner path to scanner stats
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
-91
View File
@@ -1,91 +0,0 @@
package quota
import (
"fmt"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
)
// MeasurementStatusReady means enforcement uses the configured meter.
const MeasurementStatusReady = "ready"
// MeasurementStatusAccountedFallback means index meter configured but accounted counter used.
const MeasurementStatusAccountedFallback = "accounted_fallback"
// IsIndexMeter reports whether meter counts usage from the search index.
func IsIndexMeter(meter string) bool {
return meter == MeterIndexSize || meter == MeterIndexScope
}
// IndexingEnabled reports whether folder sizes are available from the index on this source.
func IndexingEnabled(sourceName string) bool {
idx := indexing.GetIndex(sourceName)
if idx == nil {
return false
}
return !idx.Config.ResolvedRules.IndexingDisabled
}
// IndexSizeAvailable reports whether GetFolderSize succeeds for the quota root path.
func IndexSizeAvailable(sourceName, indexPath string) bool {
idx := indexing.GetIndex(sourceName)
if idx == nil || idx.Config.ResolvedRules.IndexingDisabled {
return false
}
path := normalizeMeterPath(indexPath)
_, ok := idx.GetFolderSizeForIndexPath(path)
return ok
}
// EffectiveMeter resolves runtime meter: accounted when indexing off, configured index when size known, else accounted fallback.
func EffectiveMeter(configuredMeter, sourceName, quotaRootPath string) string {
if configuredMeter == MeterAccounted || !IsIndexMeter(configuredMeter) {
return MeterAccounted
}
if !IndexingEnabled(sourceName) {
return MeterAccounted
}
if IndexSizeAvailable(sourceName, quotaRootPath) {
return configuredMeter
}
return MeterAccounted
}
// MeasurementStatus compares configured vs effective meter for API snapshots.
func MeasurementStatus(configuredMeter, effectiveMeter string) string {
if IsIndexMeter(configuredMeter) && effectiveMeter == MeterAccounted {
return MeasurementStatusAccountedFallback
}
return MeasurementStatusReady
}
// ValidateConfiguredMeter rejects index meters when indexing is disabled on the source.
func ValidateConfiguredMeter(sourceName, meter string) error {
meter = strings.TrimSpace(meter)
if meter == "" {
return nil
}
if meter == MeterAccounted {
return nil
}
if IsIndexMeter(meter) {
if !IndexingEnabled(sourceName) {
return fmt.Errorf("index meter not allowed when indexing is disabled on source %q", sourceName)
}
return nil
}
return fmt.Errorf("invalid quota meter %q", meter)
}
func normalizeMeterPath(path string) string {
path = strings.TrimSpace(path)
path = strings.TrimSuffix(path, "/")
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
@@ -1,116 +0,0 @@
package quota_test
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupMeterTestSource(t *testing.T, name, path string, indexingDisabled bool) {
t.Helper()
prevSourceMap := settings.Config.Server.SourceMap
prevNameToSource := settings.Config.Server.NameToSource
t.Cleanup(func() {
indexing.ClearTestIndices()
settings.Config.Server.SourceMap = prevSourceMap
settings.Config.Server.NameToSource = prevNameToSource
})
indexing.SetTestIndex(name, path)
idx := indexing.GetIndex(name)
if idx == nil {
t.Fatal("expected test index")
}
idx.Config.ResolvedRules.IndexingDisabled = indexingDisabled
settings.Config.Server.SourceMap = map[string]*settings.Source{
path: {Path: path, Name: name},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
name: settings.Config.Server.SourceMap[path],
}
}
func TestValidateConfiguredMeter_IndexingDisabled(t *testing.T) {
root := t.TempDir()
name := "meter-src"
setupMeterTestSource(t, name, root, true)
if err := quota.ValidateConfiguredMeter(name, quota.MeterIndexSize); err == nil {
t.Fatal("expected error for index_size when indexing disabled")
}
if err := quota.ValidateConfiguredMeter(name, quota.MeterIndexScope); err == nil {
t.Fatal("expected error for index_scope when indexing disabled")
}
if err := quota.ValidateConfiguredMeter(name, quota.MeterAccounted); err != nil {
t.Fatalf("accounted should be allowed: %v", err)
}
}
func TestEffectiveMeter_IndexingDisabledForcesAccounted(t *testing.T) {
root := t.TempDir()
name := "meter-src2"
setupMeterTestSource(t, name, root, true)
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/")
if got != quota.MeterAccounted {
t.Fatalf("expected accounted, got %q", got)
}
}
func TestEffectiveMeter_ExplicitAccountedWhenIndexOn(t *testing.T) {
root := t.TempDir()
name := "meter-src3"
setupMeterTestSource(t, name, root, false)
idx := indexing.GetIndex(name)
idx.SetFolderSize("/", 1024)
got := quota.EffectiveMeter(quota.MeterAccounted, name, "/")
if got != quota.MeterAccounted {
t.Fatalf("expected accounted, got %q", got)
}
}
func TestEffectiveMeter_IndexWhenSizeAvailable(t *testing.T) {
root := t.TempDir()
name := "meter-src4"
setupMeterTestSource(t, name, root, false)
idx := indexing.GetIndex(name)
idx.SetFolderSize("/", 2048)
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/")
if got != quota.MeterIndexSize {
t.Fatalf("expected index_size, got %q", got)
}
status := quota.MeasurementStatus(quota.MeterIndexSize, got)
if status != quota.MeasurementStatusReady {
t.Fatalf("expected ready, got %q", status)
}
}
func TestEffectiveMeter_IndexPathTrailingSlash(t *testing.T) {
root := t.TempDir()
name := "meter-src6"
setupMeterTestSource(t, name, root, false)
idx := indexing.GetIndex(name)
idx.SetFolderSize("/projects/", 4096)
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/projects")
if got != quota.MeterIndexSize {
t.Fatalf("expected index_size for path without trailing slash, got %q", got)
}
}
func TestEffectiveMeter_RuntimeFallback(t *testing.T) {
root := t.TempDir()
name := "meter-src5"
setupMeterTestSource(t, name, root, false)
got := quota.EffectiveMeter(quota.MeterIndexScope, name, "/missing")
if got != quota.MeterAccounted {
t.Fatalf("expected accounted fallback, got %q", got)
}
status := quota.MeasurementStatus(quota.MeterIndexScope, got)
if status != quota.MeasurementStatusAccountedFallback {
t.Fatalf("expected accounted_fallback, got %q", status)
}
}
-43
View File
@@ -1,43 +0,0 @@
package quota
// Meter names for scope and folder quotas.
const (
MeterIndexScope = "index_scope"
MeterIndexSize = "index_size"
MeterAccounted = "accounted"
)
// FolderQuota is a path-bound storage cap with configurable meter.
type FolderQuota struct {
ID string `json:"id"`
Source string `json:"source"` // backend source path
Path string `json:"path"` // index path within source
UserID uint64 `json:"userId,omitempty"` // 0 = all users
LimitBytes int64 `json:"limitBytes"`
Meter string `json:"meter,omitempty"` // index_size | index_scope (indexed size) | accounted (tracked usage)
}
// Counter holds accounted usage for a quota_id.
type Counter struct {
QuotaID string `json:"quotaId"`
UsedBytes int64 `json:"usedBytes"`
ReservedBytes int64 `json:"reservedBytes"`
}
// Snapshot is a read model for API and admission checks.
type Snapshot struct {
QuotaID string `json:"quotaId"`
Kind string `json:"quotaKind"` // folder, scope, share
LimitBytes int64 `json:"limitBytes"`
UsedBytes int64 `json:"usedBytes"`
ReservedBytes int64 `json:"reservedBytes"`
Meter string `json:"meter,omitempty"` // effective meter (backward compatible)
ConfiguredMeter string `json:"configuredMeter,omitempty"`
EffectiveMeter string `json:"effectiveMeter,omitempty"`
MeasurementStatus string `json:"measurementStatus,omitempty"` // ready, accounted_fallback
}
// ShareQuotaID returns the counter key for a share quota.
func ShareQuotaID(hash string) string {
return "share:" + hash
}
+2 -19
View File
@@ -48,7 +48,6 @@ type ShareLimits struct {
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles,omitempty"`
DownloadsLimit int `json:"downloadsLimit,omitempty"`
QuotaLimitBytes int64 `json:"quotaLimitBytes,omitempty"`
HideFileExt string `json:"hideFileExt,omitempty"` // show hidden files based on extensions in shares
Banner string `json:"banner,omitempty"`
SourceName string `json:"source,omitempty"` // source display name for API; backend path is Share.SourcePath
@@ -86,11 +85,8 @@ type ShareEditable struct {
type ShareFrontend struct {
ShareEditable
ShareColumns
Username string `json:"username,omitempty"`
PathExists bool `json:"pathExists,omitempty"`
QuotaUsedBytes int64 `json:"quotaUsedBytes,omitempty"`
QuotaReservedBytes int64 `json:"quotaReservedBytes,omitempty"`
QuotaAvailableBytes int64 `json:"quotaAvailableBytes,omitempty"`
Username string `json:"username,omitempty"`
PathExists bool `json:"pathExists,omitempty"`
}
// SharePostBody is POST /api/share JSON. Plaintext password is hashed to Share.PasswordHash before persist.
@@ -102,19 +98,6 @@ type SharePostBody struct {
Path string `json:"path,omitempty"`
}
// EditableFromShare returns the client-editable subset of a persisted share.
// ShareExpiryInput is omitted because expiration is stored in ShareColumns.Expire;
// callers merging partial updates must preserve Expire when expires/unit are absent.
func EditableFromShare(s *Share) ShareEditable {
if s == nil {
return ShareEditable{}
}
return ShareEditable{
FrontendShareInfo: s.FrontendShareInfo,
ShareLimits: s.ShareLimits,
}
}
// ApplyPostBodyUpdate copies client-editable fields onto link.
// Caller must preserve path, sourcePath, pinnedItems, version, download counters, and secrets.
func ApplyPostBodyUpdate(link *Share, req *SharePostBody, expire int64) {
+3 -8
View File
@@ -145,20 +145,15 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
if l == nil || l.SourcePath != oldSource {
continue
}
shareWasDir := strings.HasSuffix(l.Path, "/")
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
norm := utils.AddTrailingSlashIfNotExists(l.Path)
if !strings.HasPrefix(norm, oldPath) {
pos := strings.Index(l.Path, oldPath)
if pos < 0 {
continue
}
l.SourcePath = newSource
suffix := strings.TrimPrefix(norm, oldPath)
l.Path = strings.TrimSuffix(newPath+suffix, "/")
if shareWasDir {
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
}
l.Path = newPath
if err := s.back.Save(l); err != nil {
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
@@ -1,200 +0,0 @@
package sql
import (
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
// TestCreatedTime_InsertAndReadBack checks that an inserted Created value is returned unchanged.
func TestCreatedTime_InsertAndReadBack(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_set", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
created := time.Unix(1700000000, 0)
info := &iteminfo.FileInfo{
Path: "/a.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "a.txt",
Size: 12,
Created: &created,
ModTime: time.Unix(1, 0),
Type: "text/plain",
},
}
if insertErr := db.InsertItem("src1", "/a.txt", info); insertErr != nil {
t.Fatal(insertErr)
}
got, err := db.GetItem("src1", "/a.txt")
if err != nil {
t.Fatal(err)
}
if got.Created == nil {
t.Fatal("expected non-nil Created")
}
if !got.Created.Equal(created.Truncate(time.Second)) {
t.Errorf("Created = %v, want %v", got.Created, created.Truncate(time.Second))
}
}
// TestCreatedTime_NilStaysNil checks that an item inserted without Created reads back with nil.
func TestCreatedTime_NilStaysNil(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_nil", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
info := &iteminfo.FileInfo{
Path: "/b.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "b.txt",
Size: 12,
ModTime: time.Unix(1, 0),
Type: "text/plain",
},
}
if insertErr := db.InsertItem("src1", "/b.txt", info); insertErr != nil {
t.Fatal(insertErr)
}
got, err := db.GetItem("src1", "/b.txt")
if err != nil {
t.Fatal(err)
}
if got.Created != nil {
t.Errorf("expected nil Created, got %v", got.Created)
}
}
// TestCreatedTime_UpsertSetsCreated checks that an upsert fills in a previously nil Created.
func TestCreatedTime_UpsertSetsCreated(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_upsert", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
info := &iteminfo.FileInfo{
Path: "/c.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "c.txt",
Size: 12,
ModTime: time.Unix(1, 0),
Type: "text/plain",
},
}
if insertErr := db.InsertItem("src1", "/c.txt", info); insertErr != nil {
t.Fatal(insertErr)
}
created := time.Unix(1700000000, 0)
info.Created = &created
if updateErr := db.InsertItem("src1", "/c.txt", info); updateErr != nil {
t.Fatal(updateErr)
}
got, err := db.GetItem("src1", "/c.txt")
if err != nil {
t.Fatal(err)
}
if got.Created == nil {
t.Fatal("expected non-nil Created after upsert")
}
if !got.Created.Equal(created.Truncate(time.Second)) {
t.Errorf("Created = %v, want %v", got.Created, created.Truncate(time.Second))
}
}
// TestCreatedTime_MigrationAddsColumn checks that CreateIndexTable adds created_time to an
// old-schema table and can be run repeatedly.
func TestCreatedTime_MigrationAddsColumn(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_migration", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`DROP TABLE index_items`); err != nil {
t.Fatal(err)
}
oldSchema := `
CREATE TABLE index_items (
source TEXT NOT NULL,
path TEXT NOT NULL,
parent_path TEXT NOT NULL,
name TEXT NOT NULL,
size INTEGER NOT NULL,
mod_time INTEGER NOT NULL,
type TEXT NOT NULL,
is_dir BOOLEAN NOT NULL,
is_hidden BOOLEAN NOT NULL,
has_preview BOOLEAN NOT NULL,
last_updated INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (source, path)
)`
if _, err := db.Exec(oldSchema); err != nil {
t.Fatal(err)
}
if err := db.CreateIndexTable(); err != nil {
t.Fatal(err)
}
if !hasCreatedTimeColumn(t, db) {
t.Fatal("expected created_time column after migration")
}
// Running again must not error (repeatable migration).
if err := db.CreateIndexTable(); err != nil {
t.Fatal(err)
}
if !hasCreatedTimeColumn(t, db) {
t.Fatal("expected created_time column after second CreateIndexTable call")
}
}
// hasCreatedTimeColumn reports whether index_items currently has a created_time column.
func hasCreatedTimeColumn(t *testing.T, db *IndexDB) bool {
t.Helper()
rows, err := db.Query(`PRAGMA table_info(index_items)`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var cid, notNull, pk int
var name, columnType string
var defaultValue interface{}
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &pk); err != nil {
t.Fatal(err)
}
if name == "created_time" {
return true
}
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return false
}
Loaded 100 of 811 files, more files were not shown because too many files have changed in this diff. Show more