mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 08:42:06 -05:00
Compare commits
116
Commits
v2.1.0-beta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8415d5f1ff | ||
|
|
85f934208c | ||
|
|
1022ecafef | ||
|
|
062ac9fb2d | ||
|
|
cecdd79fcc | ||
|
|
ac50ecfd18 | ||
|
|
71cc2f3f1b | ||
|
|
1b3f47f176 | ||
|
|
219b64b5ec | ||
|
|
3ef4928ed7 | ||
|
|
df0a5ad10c | ||
|
|
a43d0ab2d3 | ||
|
|
9de09e5ec8 | ||
|
|
9d38c686a1 | ||
|
|
1cfca112e4 | ||
|
|
f540e22dc9 | ||
|
|
dade856ebe | ||
|
|
2f1da21253 | ||
|
|
de8d2acd43 | ||
|
|
6375be202e | ||
|
|
816b99cee0 | ||
|
|
bf349a7dcd | ||
|
|
db6504bf80 | ||
|
|
2019d573f8 | ||
|
|
d716fc15ec | ||
|
|
56e16608e4 | ||
|
|
c471fc135b | ||
|
|
5112cf87b1 | ||
|
|
3d7bac47ee | ||
|
|
2a38d2fa1e | ||
|
|
82e110c08d | ||
|
|
f4c96078a1 | ||
|
|
975adedabb | ||
|
|
14ec0eba0d | ||
|
|
307d5f6591 | ||
|
|
ff9df43356 | ||
|
|
802e91aa05 | ||
|
|
28ef44cdec | ||
|
|
0ee0f35301 | ||
|
|
cc99aa542e | ||
|
|
8514da4a9a | ||
|
|
4625b89528 | ||
|
|
b2a07196b7 | ||
|
|
0a8698ee4d | ||
|
|
ec8ad36c95 | ||
|
|
2c0c1b4bb2 | ||
|
|
6fa71bd13d | ||
|
|
06aa16e7d7 | ||
|
|
b0f6d53f49 | ||
|
|
d27ab6a00b | ||
|
|
f0fb859479 | ||
|
|
4c8a913377 | ||
|
|
c1684baffd | ||
|
|
bb45ebcce1 | ||
|
|
18138b1ab5 | ||
|
|
26665efc13 | ||
|
|
98851e029f | ||
|
|
4a1a3bbe09 | ||
|
|
a6d7f7c64c | ||
|
|
ada7a18620 | ||
|
|
4f4ea2370e | ||
|
|
52aa314e1d | ||
|
|
1d6f67cc20 | ||
|
|
d89b7e7deb | ||
|
|
e26bd29848 | ||
|
|
9c2b98655a | ||
|
|
a379f047d1 | ||
|
|
f4879be6bf | ||
|
|
7751da745d | ||
|
|
80cedcb4d5 | ||
|
|
ac19abf7d2 | ||
|
|
38e868e5af | ||
|
|
b3af5cc2d7 | ||
|
|
b399de3d92 | ||
|
|
99f1a14e7b | ||
|
|
84afd8b0b7 | ||
|
|
78be5d8c0f | ||
|
|
5e29e1ee60 | ||
|
|
367a7f019c | ||
|
|
fa9c094160 | ||
|
|
36a9a6dc49 | ||
|
|
8fe6f1d5c3 | ||
|
|
6dc3935c57 | ||
|
|
5e73036657 | ||
|
|
e505900975 | ||
|
|
26f320ee43 | ||
|
|
184ae798c4 | ||
|
|
7ae8bab062 | ||
|
|
1af606e751 | ||
|
|
3683bc607c | ||
|
|
08d3e43c22 | ||
|
|
d76f9373c4 | ||
|
|
a72651e7aa | ||
|
|
73d0b20821 | ||
|
|
3e447307f7 | ||
|
|
264cc81c41 | ||
|
|
aa5b24119a | ||
|
|
52cf624711 | ||
|
|
5753f63970 | ||
|
|
8f1cf54cb0 | ||
|
|
a8cfcdd6eb | ||
|
|
91bc608f0e | ||
|
|
e21d553559 | ||
|
|
fde6857344 | ||
|
|
37aec9ebd8 | ||
|
|
4bf1ba312a | ||
|
+5 |
c8459eb404 | ||
|
|
241bbd69b4 | ||
|
|
9de21cd896 | ||
|
|
fcec78b57c | ||
|
|
12f27133b5 | ||
|
|
20a5d75d7e | ||
|
+5 |
8f2e8add9a | ||
|
|
6e98a9d4c8 | ||
|
|
c74817b1ab | ||
|
|
ed9b29f2e1 |
No files matched your search
+101
@@ -0,0 +1,101 @@
|
||||
#!/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"
|
||||
@@ -35,44 +35,3 @@ jobs:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
|
||||
create_beta_release:
|
||||
needs: [test_playwright]
|
||||
if: startsWith(github.event.head_commit.message, 'Beta/v')
|
||||
name: Create Beta Release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
fetch-depth: 0
|
||||
- name: Extract version from commit message
|
||||
id: extract_version
|
||||
shell: bash
|
||||
run: |
|
||||
tag_name=$(echo "${{ github.event.head_commit.message }}" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
|
||||
if [ -z "$tag_name" ] || [ "$tag_name" = "-beta" ]; then
|
||||
echo "Could not extract version from: ${{ github.event.head_commit.message }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
|
||||
- name: Tag release commit on main
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
TAG="${{ steps.extract_version.outputs.tag_name }}"
|
||||
git tag -f "$TAG" "${{ github.sha }}"
|
||||
git push origin "refs/tags/$TAG" --force
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_version.outputs.tag_name }}
|
||||
prerelease: false
|
||||
make_latest: false
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
overwrite_existing: true
|
||||
name: ${{ steps.extract_version.outputs.tag_name }}
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
jobs:
|
||||
test-backend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
@@ -17,6 +18,7 @@ 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
|
||||
@@ -28,6 +30,7 @@ jobs:
|
||||
cd backend && "$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
|
||||
@@ -37,21 +40,17 @@ 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
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i
|
||||
- name: Lint frontend
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
- name: Lint frontend CSS
|
||||
working-directory: frontend
|
||||
run: npm run lint:css
|
||||
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
|
||||
@@ -61,6 +60,7 @@ 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,6 +70,7 @@ 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
|
||||
@@ -79,10 +80,11 @@ 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
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run icons:check
|
||||
run: npm i && npm run icons:check
|
||||
+34
-18
@@ -61,18 +61,23 @@ 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 '
|
||||
MODIFIED_JSON=$(echo "$JSON" | jq \
|
||||
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
|
||||
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
|
||||
.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 and major tags
|
||||
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor (always) and major tags (when allowed)
|
||||
.tags += [
|
||||
.tags[]
|
||||
.tags[]
|
||||
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
|
||||
| split(":")
|
||||
| .[-1] as $version
|
||||
@@ -83,18 +88,20 @@ jobs:
|
||||
| ($version_parts[0]) as $major
|
||||
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
if $updateMajor then
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
else empty end
|
||||
] |
|
||||
# Add suffix-only tags for beta (only if tag ends with -beta)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-beta")) then
|
||||
# Add suffix-only tags for beta when semver policy allows
|
||||
if $updateTrack and 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) (only if tag ends with -stable)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-stable")) then
|
||||
# Add suffix-only tags for stable (and latest) when semver policy allows
|
||||
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:stable",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable",
|
||||
@@ -146,19 +153,26 @@ 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 '
|
||||
MODIFIED_JSON=$(echo "$JSON" | jq \
|
||||
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
|
||||
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
|
||||
.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 and major tags
|
||||
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor (always) and major tags (when allowed)
|
||||
.tags += [
|
||||
.tags[]
|
||||
.tags[]
|
||||
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
|
||||
| split(":")
|
||||
| .[-1] as $version
|
||||
@@ -169,18 +183,20 @@ jobs:
|
||||
| ($version_parts[0]) as $major
|
||||
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
if $updateMajor then
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
else empty end
|
||||
] |
|
||||
# Add suffix-only tags for beta-slim (only if tag ends with -beta-slim)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
|
||||
# Add suffix-only tags for beta-slim when semver policy allows
|
||||
if $updateTrack and 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 (only if tag ends with -stable-slim)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
|
||||
# Add suffix-only tags for stable-slim when semver policy allows
|
||||
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:stable-slim",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
|
||||
|
||||
@@ -46,6 +46,7 @@ frontend/.tmp-icons-*
|
||||
/backend/internal/web/dist
|
||||
/backend/internal/web/embed
|
||||
/backend/debug
|
||||
/backend/http
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
|
||||
+134
@@ -2,6 +2,115 @@
|
||||
|
||||
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**:
|
||||
@@ -70,6 +179,31 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- 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**:
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
|
||||
|
||||
:pushpin: [v2.0.0 is now in beta!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/)
|
||||
: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)
|
||||
|
||||
## About
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM gtstef/ffmpeg:9.0.1-decode AS ffmpeg
|
||||
FROM gtstef/ffmpeg:9.0.2-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
|
||||
@@ -14,5 +14,9 @@ server:
|
||||
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: 5000,
|
||||
timeout: 60_000,
|
||||
testDir: "./tests/playwright/screenshots",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
|
||||
+23
-23
@@ -28,7 +28,7 @@ require (
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.67.4 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // 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
|
||||
@@ -45,7 +45,7 @@ github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // i
|
||||
github.com/blizzy78/varnamelen v0.8.0 // indirect
|
||||
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
|
||||
github.com/bombsimon/wsl/v5 v5.9.0 // indirect
|
||||
github.com/breml/bidichk v0.3.3 // indirect
|
||||
github.com/breml/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
|
||||
@@ -54,7 +54,7 @@ github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // i
|
||||
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/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
|
||||
@@ -101,16 +101,16 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indir
|
||||
github.com/gohugoio/hashstructure v1.1.0 // indirect
|
||||
github.com/gohugoio/hugo v0.165.0 // indirect
|
||||
github.com/golangci/asciicheck v0.5.0 // indirect
|
||||
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect
|
||||
github.com/golangci/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.14.0 // indirect
|
||||
github.com/golangci/golines v0.15.0 // 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-20260901154044-241134db7241 // 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
|
||||
@@ -129,10 +129,10 @@ github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
|
||||
github.com/kisielk/errcheck v1.20.0 // indirect
|
||||
github.com/kisielk/errcheck v1.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.15 // 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
|
||||
@@ -147,17 +147,17 @@ github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
|
||||
github.com/maratori/testableexamples v1.0.1 // indirect
|
||||
github.com/maratori/testpackage v1.1.2 // indirect
|
||||
github.com/matoous/godox v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-colorable v0.1.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.0 // 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.13.0 // indirect
|
||||
github.com/nishanths/predeclared v0.2.2 // 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
|
||||
@@ -200,15 +200,15 @@ github.com/mgechev/revive v1.17.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.12.0 // indirect
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 // 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.2.1 // indirect
|
||||
github.com/uudashr/iface v1.5.1 // 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
|
||||
@@ -225,14 +225,14 @@ github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/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.50.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.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
|
||||
|
||||
@@ -124,6 +124,8 @@ github.com/bombsimon/wsl/v5 v5.9.0 h1:WCrgZ7RQnZO5oEwbVTlYgBdU3wL294kR1BSWV8vTfs
|
||||
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=
|
||||
@@ -288,6 +290,8 @@ github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4n
|
||||
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=
|
||||
@@ -298,8 +302,12 @@ github.com/golangci/golangci-lint/v2 v2.13.2 h1:bCyq3E4vo9qwzifjpzJqYndEt7Ncva80
|
||||
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=
|
||||
@@ -308,6 +316,8 @@ github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2
|
||||
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=
|
||||
@@ -365,6 +375,8 @@ github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhE
|
||||
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=
|
||||
@@ -378,6 +390,8 @@ 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=
|
||||
@@ -416,6 +430,8 @@ 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=
|
||||
@@ -424,6 +440,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=
|
||||
@@ -444,8 +462,13 @@ github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlM
|
||||
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=
|
||||
@@ -581,8 +604,12 @@ github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 h1:HECVafB2AKntW
|
||||
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=
|
||||
@@ -593,8 +620,12 @@ 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=
|
||||
@@ -661,6 +692,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
|
||||
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=
|
||||
@@ -672,6 +705,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
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=
|
||||
@@ -681,6 +715,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
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=
|
||||
@@ -696,6 +732,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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=
|
||||
@@ -718,6 +756,8 @@ 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=
|
||||
@@ -736,6 +776,8 @@ 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=
|
||||
|
||||
+38
-20
@@ -8,7 +8,6 @@ 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"
|
||||
@@ -225,19 +224,11 @@ 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)
|
||||
}
|
||||
|
||||
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)
|
||||
config.Server.Logging = []settings.LogConfig{
|
||||
{
|
||||
Output: "stdout",
|
||||
Levels: "info|warning|error",
|
||||
},
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -250,9 +241,20 @@ 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)
|
||||
}
|
||||
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
|
||||
config.Auth.Methods.PasswordAuth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
|
||||
config.Auth.Methods.PasswordAuth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
|
||||
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
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -266,12 +268,12 @@ func createConfig(configpath string, noInput bool) error {
|
||||
|
||||
fmt.Println("--- Configuration Complete ---")
|
||||
|
||||
yamlData, err := yaml.Marshal(&config)
|
||||
yamlData, err := generateSetupConfigYAML(&config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
return fmt.Errorf("generating config YAML: %w", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(configpath, yamlData, 0600)
|
||||
err = os.WriteFile(configpath, []byte(yamlData), 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing config file: %w", err)
|
||||
}
|
||||
@@ -291,6 +293,22 @@ 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 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -191,3 +192,26 @@ 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:")
|
||||
}
|
||||
@@ -289,6 +289,9 @@ 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.
|
||||
|
||||
@@ -27,6 +27,9 @@ 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
|
||||
@@ -50,7 +53,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("createUserDir scope=%q", user.BackendScopes[0].Scope)
|
||||
t.Fatalf("scope after applying migration defaults=%q", user.BackendScopes[0].Scope)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ server:
|
||||
|
||||
userDefaults:
|
||||
fileViewer:
|
||||
editorQuickSave: false
|
||||
disableOnlyOfficeExt: ".md .txt .pdf"
|
||||
disableViewingExt: ""
|
||||
sidebar:
|
||||
|
||||
+18
-18
@@ -11,7 +11,7 @@ require (
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/evanoberholster/imagemeta v1.1.0
|
||||
github.com/gen2brain/go-fitz v1.28.2
|
||||
github.com/go-ldap/ldap/v3 v3.4.14
|
||||
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/goccy/go-yaml v1.19.2
|
||||
@@ -19,24 +19,25 @@ require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.2.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.4.0
|
||||
github.com/gtsteffaniak/go-logger v1.1.0
|
||||
github.com/kovidgoyal/imaging v1.8.23
|
||||
github.com/kovidgoyal/imaging v1.9.0
|
||||
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.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/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/oauth2 v0.37.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.org/x/term v0.46.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/time v0.16.0
|
||||
golang.org/x/tools v0.50.0
|
||||
golang.org/x/tools v0.52.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.60.1
|
||||
)
|
||||
@@ -48,12 +49,12 @@ require (
|
||||
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.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // 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/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.5 // 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-openapi/spec v1.0.1 // indirect
|
||||
@@ -79,7 +80,7 @@ require (
|
||||
github.com/leodido/go-urn v1.5.0 // 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.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
@@ -89,13 +90,12 @@ require (
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.etcd.io/bbolt v1.5.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/libc v1.77.1 // indirect
|
||||
modernc.org/libc v1.79.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
|
||||
+42
-42
@@ -47,14 +47,14 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
|
||||
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.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
|
||||
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||
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.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
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/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/gen2brain/go-fitz v1.28.2 h1:845G85N5TUgnq5oDqyYrW0JvehAkeo35UkkK2dJtW1M=
|
||||
github.com/gen2brain/go-fitz v1.28.2/go.mod h1:pY2hqAjp9Zy7qfPI2gwbJMHBFAdZpVXOLrRxD82l3Bs=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
@@ -65,10 +65,10 @@ github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWE
|
||||
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.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-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=
|
||||
@@ -143,8 +143,8 @@ 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/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.2.0 h1:WSFfRL5xVUpEXBNI4D3f2Uz8yxbc3OEqTtjp4Z9yPUo=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.2.0/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
|
||||
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-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
|
||||
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
@@ -171,8 +171,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui
|
||||
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.8.23 h1:4WsboyQ/E8tic2kX49P93Rvscg33SPsCwTBG5EZZF54=
|
||||
github.com/kovidgoyal/imaging v1.8.23/go.mod h1:k3Iot3H0v2rSWXIxQISfT3e9wIryqzAHLHCuhG7z9lM=
|
||||
github.com/kovidgoyal/imaging v1.9.0 h1:TxHvhClPANvDW7eLm7PYDTZU1KE15xbqlFv95DA6DmY=
|
||||
github.com/kovidgoyal/imaging v1.9.0/go.mod h1:t2vWMc3uISm/jMP87nBCcZUcAK6EDUdgumwaXSuz0Jc=
|
||||
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=
|
||||
@@ -189,8 +189,8 @@ github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdo
|
||||
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.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -233,13 +233,13 @@ 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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
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/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
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-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=
|
||||
@@ -251,14 +251,14 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
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.7.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/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
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-20220722155255-886fb9371eb4/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-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -270,28 +270,28 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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.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/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||
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.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.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
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/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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
@@ -311,20 +311,20 @@ 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.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
|
||||
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
|
||||
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
|
||||
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=
|
||||
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.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
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/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
|
||||
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
|
||||
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
|
||||
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
|
||||
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=
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../frontend/dist
|
||||
@@ -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",
|
||||
"sidebarLinks", "deleteAfterArchive", "preferEditorForMarkdown", "showFirstLogin", "requirePasswordChange",
|
||||
}
|
||||
|
||||
changes := UserUpdateChanges(before, &after, which, false)
|
||||
|
||||
@@ -163,8 +163,18 @@ 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) {
|
||||
RecordUser(r, actor, activitydb.Entry{
|
||||
recordFileEvent(r, actor, activitydb.Entry{
|
||||
EventType: activitydb.EventDelete,
|
||||
Source: source,
|
||||
Path: path,
|
||||
@@ -198,7 +208,7 @@ func RecordBulkDelete(r *http.Request, actor *Actor, succeeded []BulkDeleteItem)
|
||||
entry.Details.Source = succeeded[0].Source
|
||||
entry.Details.Path = succeeded[0].Path
|
||||
}
|
||||
RecordUser(r, actor, entry)
|
||||
recordFileEvent(r, actor, entry)
|
||||
}
|
||||
|
||||
func RecordUpload(r *http.Request, actor *Actor, source, path string, isDir bool) {
|
||||
@@ -210,7 +220,7 @@ func RecordUpload(r *http.Request, actor *Actor, source, path string, isDir bool
|
||||
Source: source,
|
||||
Path: pathLabel,
|
||||
}
|
||||
RecordUser(r, actor, activitydb.Entry{
|
||||
recordFileEvent(r, actor, activitydb.Entry{
|
||||
EventType: activitydb.EventUpload,
|
||||
Source: source,
|
||||
Path: pathLabel,
|
||||
@@ -218,6 +228,20 @@ 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,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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) {
|
||||
@@ -28,6 +32,86 @@ 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 {
|
||||
|
||||
@@ -3,7 +3,6 @@ package activity
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
@@ -11,14 +10,19 @@ 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 cfg.Http.TrustProxyHeaders && xff != "" {
|
||||
ips := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(ips[0])
|
||||
if trustedPeer && xff != "" {
|
||||
if ip := settings.TrustedProxyClientIP(xff); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
xri := r.Header.Get("X-Real-IP")
|
||||
if cfg.Http.TrustProxyHeaders && xri != "" {
|
||||
if trustedPeer && xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
|
||||
liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"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", realTmpDir, true)
|
||||
err := DeleteFiles("test_root_protection", "", "/", 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", realTmpDir+"/", true)
|
||||
err = DeleteFiles("test_root_protection", "", "/", 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", realTmpDir+"/.", true)
|
||||
err = DeleteFiles("test_root_protection", "", "/", 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, subfolderPath, true)
|
||||
err := DeleteFiles(sourceName, "", "/"+subfolderName, true)
|
||||
stopPolling()
|
||||
|
||||
t.Logf("iteration %d: DeleteFiles finished in %s (err=%v)", iter, time.Since(start), err)
|
||||
@@ -694,13 +694,130 @@ 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 {
|
||||
|
||||
@@ -71,13 +71,18 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if opts.Metadata && metadataCount > 0 {
|
||||
addMetadataToChildren(response, opts, &wg)
|
||||
@@ -330,6 +335,15 @@ 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
|
||||
@@ -629,47 +643,53 @@ func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteFiles(source, absPath string, isDir bool) error {
|
||||
// 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 {
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
// 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)
|
||||
if utils.CleanIndexPathSegment(indexPath) == "" {
|
||||
return fmt.Errorf("refusing to delete source root directory: %s", indexPath)
|
||||
}
|
||||
|
||||
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)
|
||||
realPath, err := index.GetRealPathForRemoval(boundIndexPath, indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Indexing disabled, just delete the file
|
||||
return os.RemoveAll(absPath)
|
||||
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
|
||||
}
|
||||
|
||||
func RefreshIndex(source string, path string, isDir bool, recursive bool) error {
|
||||
@@ -896,15 +916,19 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteDirectory(opts utils.FileOptions) error {
|
||||
// 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 {
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(opts.Path)
|
||||
realPath, err := idx.GetRealPathForMutation(boundIndexPath, opts.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
@@ -948,21 +972,24 @@ func WriteDirectory(opts utils.FileOptions) error {
|
||||
// WriteFileFunc is a variable that can be mocked in tests
|
||||
var WriteFileFunc = writeFile
|
||||
|
||||
func WriteFile(source, path string, in io.Reader) error {
|
||||
return WriteFileFunc(source, path, in)
|
||||
// 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, path string, in io.Reader) error {
|
||||
func writeFile(source, boundIndexPath, path string, in io.Reader) error {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
// Strip trailing slash from realPath if it's meant to be a file
|
||||
realPath = strings.TrimRight(realPath, "/")
|
||||
realPath, err := idx.GetRealPathForMutation(boundIndexPath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -19,11 +19,13 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,22 @@ 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)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
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.
|
||||
@@ -134,6 +150,10 @@ 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 {
|
||||
|
||||
@@ -356,6 +356,74 @@ func TestCopySymlinkReplacesExistingDestination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//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,94 +7,64 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// GetAggregatedPartitionUsage returns total and used bytes summed across distinct
|
||||
// filesystems mounted at or under root (deduped by major:minor from mountinfo).
|
||||
// Falls back to a single-path probe when mountinfo cannot be read.
|
||||
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
|
||||
// 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) {
|
||||
if root == "" {
|
||||
return 0, 0, fmt.Errorf("empty path")
|
||||
return aggregate, rootOnly, fmt.Errorf("empty path")
|
||||
}
|
||||
root = filepath.Clean(root)
|
||||
|
||||
paths, parseErr := distinctMountPathsUnder(root)
|
||||
if parseErr != nil || len(paths) == 0 {
|
||||
return singlePathPartitionUsage(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
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for _, path := range paths {
|
||||
t, u, stErr := partitionUsageAt(path)
|
||||
if stErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = stErr
|
||||
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
|
||||
}
|
||||
continue
|
||||
stats = append(stats, stat)
|
||||
}
|
||||
total += t
|
||||
used += u
|
||||
u := combineGroupUsage(stats)
|
||||
aggregate.Total += u.Total
|
||||
aggregate.Used += u.Used
|
||||
}
|
||||
|
||||
if total == 0 && used == 0 {
|
||||
if aggregate.Total == 0 && aggregate.Used == 0 {
|
||||
if firstErr != nil {
|
||||
return 0, 0, firstErr
|
||||
return aggregate, rootOnly, firstErr
|
||||
}
|
||||
return singlePathPartitionUsage(root)
|
||||
aggregate = rootOnly
|
||||
}
|
||||
return total, used, nil
|
||||
return aggregate, rootOnly, rootErr
|
||||
}
|
||||
|
||||
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) {
|
||||
func mountGroupsUnder(root string) ([]mountGroup, error) {
|
||||
data, err := readMountinfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths, err := distinctMountPathsFromMountinfo(data, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If mountinfo had no covering mount for root, ensure root is probed.
|
||||
if len(paths) == 0 {
|
||||
return []string{root}, nil
|
||||
}
|
||||
hasRoot := false
|
||||
for _, p := range paths {
|
||||
if p == root {
|
||||
hasRoot = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRoot {
|
||||
if dev, stErr := deviceIDFromPath(root); stErr == nil {
|
||||
// Avoid duplicating a device already selected under another path.
|
||||
already := false
|
||||
for _, p := range paths {
|
||||
if d, e := deviceIDFromPath(p); e == nil && d == dev {
|
||||
already = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !already {
|
||||
paths = append(paths, root)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
return mountGroupsFromMountinfo(data, root)
|
||||
}
|
||||
|
||||
func readMountinfo() (string, error) {
|
||||
@@ -107,24 +77,14 @@ func readMountinfo() (string, error) {
|
||||
return "", fmt.Errorf("mountinfo not available")
|
||||
}
|
||||
|
||||
func deviceIDFromPath(path string) (string, error) {
|
||||
var st unix.Stat_t
|
||||
if err := unix.Stat(path, &st); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%d:%d", unix.Major(uint64(st.Dev)), unix.Minor(uint64(st.Dev))), nil
|
||||
}
|
||||
|
||||
func partitionUsageAt(path string) (total, used uint64, err error) {
|
||||
func partitionStatfs(path string) (rawUsage, error) {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return 0, 0, err
|
||||
return rawUsage{}, err
|
||||
}
|
||||
bsize := blockSize(&stat)
|
||||
total = uint64(stat.Blocks) * bsize
|
||||
free := uint64(stat.Bavail) * bsize
|
||||
if total >= free {
|
||||
used = total - free
|
||||
}
|
||||
return total, used, nil
|
||||
return rawUsage{
|
||||
total: uint64(stat.Blocks) * bsize,
|
||||
avail: uint64(stat.Bavail) * bsize,
|
||||
}, nil
|
||||
}
|
||||
@@ -12,55 +12,111 @@ import (
|
||||
type mountEntry struct {
|
||||
deviceID string
|
||||
mountpoint string
|
||||
fstype string
|
||||
source string
|
||||
}
|
||||
|
||||
// distinctMountPathsFromMountinfo selects one path per distinct filesystem that
|
||||
// contributes capacity for root: the FS that owns root, plus nested mounts under
|
||||
// root. Deduped by major:minor from mountinfo.
|
||||
func distinctMountPathsFromMountinfo(mountinfo string, root string) ([]string, error) {
|
||||
// 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) {
|
||||
root = path.Clean(root)
|
||||
entries, err := parseAllMounts(mountinfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]string)
|
||||
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)
|
||||
}
|
||||
|
||||
var covering *mountEntry
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
mp := e.mountpoint
|
||||
if mp == root || strings.HasPrefix(root, mp+"/") || mp == "/" {
|
||||
if covering == nil || len(mp) > len(covering.mountpoint) {
|
||||
// >= 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) {
|
||||
covering = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if covering != nil {
|
||||
seen[covering.deviceID] = root
|
||||
// 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}})
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
// 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]
|
||||
mp := e.mountpoint
|
||||
if mp != root && !strings.HasPrefix(mp, root+"/") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[e.deviceID]; ok {
|
||||
if lastAtMountpoint[mp] != i {
|
||||
continue
|
||||
}
|
||||
seen[e.deviceID] = mp
|
||||
if isZFSType(e.fstype) && isZFSSnapshotMount(e.source, mp) {
|
||||
continue
|
||||
}
|
||||
addPath(e, mp)
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(seen))
|
||||
uniq := make(map[string]struct{}, len(seen))
|
||||
for _, p := range seen {
|
||||
if _, ok := uniq[p]; ok {
|
||||
continue
|
||||
}
|
||||
uniq[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func parseAllMounts(mountinfo string) ([]mountEntry, error) {
|
||||
@@ -88,9 +144,16 @@ 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,6 +7,25 @@ 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 := "" +
|
||||
@@ -14,11 +33,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"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
paths := allGroupPaths(groups)
|
||||
want := []string{"/srv", "/srv/data"}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("paths = %v, want %v", paths, want)
|
||||
@@ -37,11 +56,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"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
paths := allGroupPaths(groups)
|
||||
want := []string{"/srv", "/srv/data"}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("paths = %v, want %v (no duplicate probe paths)", paths, want)
|
||||
@@ -56,15 +75,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 - btrfs /dev/sdb1 rw\n" +
|
||||
"46 22 8:16 / /srv/data/bind rw,bind - btrfs /dev/sdb1 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"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(paths) != 2 {
|
||||
t.Fatalf("expected 2 distinct devices, got %v", paths)
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("expected 2 distinct capacity groups, got %v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,11 +93,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"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
paths := allGroupPaths(groups)
|
||||
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)
|
||||
@@ -90,10 +109,11 @@ 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"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
groups, err := mountGroupsFromMountinfo(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)
|
||||
}
|
||||
@@ -110,4 +130,124 @@ 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,17 +1,14 @@
|
||||
//go:build !linux
|
||||
//go:build !linux && !freebsd
|
||||
|
||||
package fileutils
|
||||
|
||||
// GetAggregatedPartitionUsage returns total and used bytes for the filesystem
|
||||
// that owns root. Non-Linux builds use a single statfs/GetDiskFreeSpaceEx path.
|
||||
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
|
||||
total, err = GetPartitionSize(root)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return aggregate, rootOnly, err
|
||||
}
|
||||
used, err = GetPartitionUsed(root)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return total, used, nil
|
||||
return u, u, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//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/")
|
||||
}
|
||||
@@ -301,7 +301,7 @@ func detectRuntime() string {
|
||||
|
||||
func mapSystemOS(goos string) string {
|
||||
switch goos {
|
||||
case "linux", "windows", "darwin":
|
||||
case "linux", "windows", "darwin", "freebsd":
|
||||
return goos
|
||||
default:
|
||||
return "other"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,10 @@ 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.
|
||||
@@ -39,18 +40,24 @@ 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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
return AuthenticatePassword(r, auther.ReCaptcha, false)
|
||||
}
|
||||
@@ -30,7 +30,8 @@ func SetDefault(s *Service) {
|
||||
}
|
||||
|
||||
// AuthenticatePassword authenticates the user via password in request headers.
|
||||
func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users.User, error) {
|
||||
// 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) {
|
||||
if s == nil || s.users == nil {
|
||||
return nil, fmt.Errorf("auth service not configured")
|
||||
}
|
||||
@@ -63,6 +64,9 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users
|
||||
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
|
||||
@@ -80,9 +84,9 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users
|
||||
}
|
||||
|
||||
// AuthenticatePassword authenticates via the default service.
|
||||
func AuthenticatePassword(r *http.Request, disableOtp bool) (*users.User, error) {
|
||||
func AuthenticatePassword(r *http.Request, disableOtp bool, skipRequirePasswordChange bool) (*users.User, error) {
|
||||
if defaultService != nil {
|
||||
return defaultService.AuthenticatePassword(r, disableOtp)
|
||||
return defaultService.AuthenticatePassword(r, disableOtp, skipRequirePasswordChange)
|
||||
}
|
||||
return nil, fmt.Errorf("auth service not configured")
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -126,6 +124,9 @@ type RuleUpsert struct {
|
||||
type SQLPersister interface {
|
||||
SaveAccessRule(source, path string, rule *AccessRule) error
|
||||
DeleteAccessRule(source, path string) error
|
||||
MigrateAccessRulePathsLowercase(source string, oldPaths []string, newRules RuleMap) error
|
||||
AccessRulesLowercaseMigrated() (bool, error)
|
||||
SetAccessRulesLowercaseMigrated() error
|
||||
SaveGroup(name string, members StringSet) error
|
||||
DeleteGroup(name string) error
|
||||
DeleteGroupWithRules(name string, save []RuleUpsert, remove []RuleKey) error
|
||||
@@ -222,8 +223,14 @@ func accessRuleHasPayload(rule *AccessRule) bool {
|
||||
// persistRuleSQLNL upserts or deletes one access_rules row to match in-memory state.
|
||||
// Caller must hold s.mux (write lock). normalizedPath must match rule map keys (see getOrCreateRuleNL).
|
||||
func (s *Storage) persistRuleSQLNL(sourcePath, normalizedPath string) {
|
||||
if err := s.persistRuleSQLErrorNL(sourcePath, normalizedPath); err != nil {
|
||||
logger.Warningf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Storage) persistRuleSQLErrorNL(sourcePath, normalizedPath string) error {
|
||||
if s.sqlStore == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
var rule *AccessRule
|
||||
if bySrc, ok := s.AllRules[sourcePath]; ok {
|
||||
@@ -231,18 +238,35 @@ func (s *Storage) persistRuleSQLNL(sourcePath, normalizedPath string) {
|
||||
}
|
||||
if !accessRuleHasPayload(rule) {
|
||||
if err := s.sqlStore.DeleteAccessRule(sourcePath, normalizedPath); err != nil {
|
||||
logger.Warningf("access rules SQL delete (source=%s path=%s): %v", sourcePath, normalizedPath, err)
|
||||
return fmt.Errorf("access rules SQL delete (source=%s path=%s): %w", sourcePath, normalizedPath, err)
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if err := s.sqlStore.SaveAccessRule(sourcePath, normalizedPath, rule); err != nil {
|
||||
logger.Warningf("access rules SQL save (source=%s path=%s): %v", sourcePath, normalizedPath, err)
|
||||
return fmt.Errorf("access rules SQL save (source=%s path=%s): %w", sourcePath, normalizedPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) restoreRuleSQLNL(key RuleKey, rule *AccessRule) error {
|
||||
if s.sqlStore == nil {
|
||||
return nil
|
||||
}
|
||||
if !accessRuleHasPayload(rule) {
|
||||
if err := s.sqlStore.DeleteAccessRule(key.Source, key.Path); err != nil {
|
||||
return fmt.Errorf("access rules SQL delete (source=%s path=%s): %w", key.Source, key.Path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := s.sqlStore.SaveAccessRule(key.Source, key.Path, rule); err != nil {
|
||||
return fmt.Errorf("access rules SQL save (source=%s path=%s): %w", key.Source, key.Path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveRuleByPath removes a rule by normalized index path.
|
||||
func (s *Storage) RemoveRuleByPath(sourcePath string, indexPath utils.IndexPath) {
|
||||
s.RemoveRuleByPathKey(sourcePath, ruleKey(indexPath))
|
||||
s.RemoveRuleByPathKey(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
|
||||
}
|
||||
|
||||
// RemoveRuleByPathKey removes a rule by its exact storage key (for legacy migration paths).
|
||||
@@ -255,6 +279,7 @@ func (s *Storage) RemoveRuleByPathKey(sourcePath, pathKey string) {
|
||||
return
|
||||
}
|
||||
|
||||
pathKey = strings.ToLower(pathKey)
|
||||
if _, exists := rulesBySource[pathKey]; !exists {
|
||||
return
|
||||
}
|
||||
@@ -267,7 +292,7 @@ func (s *Storage) RemoveRuleByPathKey(sourcePath, pathKey string) {
|
||||
if s.sqlStore != nil {
|
||||
_ = s.sqlStore.DeleteAccessRule(sourcePath, pathKey)
|
||||
normalized := utils.AddTrailingSlashIfNotExists(pathKey)
|
||||
if normalized != pathKey {
|
||||
if normalized != pathKey && rulesBySource[normalized] == nil {
|
||||
_ = s.sqlStore.DeleteAccessRule(sourcePath, normalized)
|
||||
}
|
||||
}
|
||||
@@ -276,7 +301,7 @@ func (s *Storage) RemoveRuleByPathKey(sourcePath, pathKey string) {
|
||||
// getOrCreateRuleNL ensures a rule exists for the given source and index path.
|
||||
// The caller must hold the lock.
|
||||
func (s *Storage) getOrCreateRuleNL(sourcePath string, indexPath utils.IndexPath) *AccessRule {
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
if _, ok := s.AllRules[sourcePath]; !ok {
|
||||
s.AllRules[sourcePath] = make(RuleMap)
|
||||
}
|
||||
@@ -287,6 +312,19 @@ func (s *Storage) getOrCreateRuleNL(sourcePath string, indexPath utils.IndexPath
|
||||
Allow: RuleSet{Users: make(StringSet), Groups: make(StringSet)},
|
||||
}
|
||||
s.AllRules[sourcePath][normalizedPath] = rule
|
||||
} else {
|
||||
if rule.Deny.Users == nil {
|
||||
rule.Deny.Users = make(StringSet)
|
||||
}
|
||||
if rule.Deny.Groups == nil {
|
||||
rule.Deny.Groups = make(StringSet)
|
||||
}
|
||||
if rule.Allow.Users == nil {
|
||||
rule.Allow.Users = make(StringSet)
|
||||
}
|
||||
if rule.Allow.Groups == nil {
|
||||
rule.Allow.Groups = make(StringSet)
|
||||
}
|
||||
}
|
||||
return rule
|
||||
}
|
||||
@@ -302,7 +340,7 @@ func (s *Storage) DenyUser(sourcePath string, indexPath utils.IndexPath, usernam
|
||||
}
|
||||
rule.Deny.Users[username] = struct{}{}
|
||||
s.clearAllCaches()
|
||||
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
|
||||
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -317,7 +355,7 @@ func (s *Storage) AllowUser(sourcePath string, indexPath utils.IndexPath, userna
|
||||
}
|
||||
rule.Allow.Users[username] = struct{}{}
|
||||
s.clearAllCaches()
|
||||
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
|
||||
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -335,7 +373,7 @@ func (s *Storage) DenyGroup(sourcePath string, indexPath utils.IndexPath, groupn
|
||||
}
|
||||
rule.Deny.Groups[groupname] = struct{}{}
|
||||
s.clearAllCaches()
|
||||
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
|
||||
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -353,7 +391,7 @@ func (s *Storage) AllowGroup(sourcePath string, indexPath utils.IndexPath, group
|
||||
}
|
||||
rule.Allow.Groups[groupname] = struct{}{}
|
||||
s.clearAllCaches()
|
||||
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
|
||||
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -367,13 +405,13 @@ func (s *Storage) DenyAll(sourcePath string, indexPath utils.IndexPath) error {
|
||||
}
|
||||
rule.DenyAll = true
|
||||
s.clearAllCaches()
|
||||
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
|
||||
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Permitted checks if a username is permitted for a given sourcePath and indexPath, recursively checking parent directories.
|
||||
func (s *Storage) Permitted(sourcePath string, indexPath utils.IndexPath, username string) bool {
|
||||
indexPath = checkPath(indexPath)
|
||||
indexPath = s.checkPathForSource(sourcePath, indexPath)
|
||||
pathStr := indexPath.String()
|
||||
|
||||
// Get current version for the sourcePath
|
||||
@@ -446,7 +484,7 @@ func (s *Storage) getRuleAtExactPath(sourcePath string, indexPath utils.IndexPat
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
rule, ok := rulesBySource[ruleKey(indexPath)]
|
||||
rule, ok := rulesBySource[s.ruleKeyForNL(sourcePath, indexPath)]
|
||||
return rule, ok
|
||||
}
|
||||
|
||||
@@ -505,7 +543,7 @@ func (s *Storage) GetFrontendRules(sourcePath string, indexPath utils.IndexPath)
|
||||
}
|
||||
|
||||
// Check if path exists on filesystem
|
||||
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath.String()))
|
||||
pathExists := checkRulePathExists(sourcePath, indexPath.String())
|
||||
|
||||
frontendRules := FrontendAccessRule{
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
@@ -523,7 +561,7 @@ func (s *Storage) GetFrontendRules(sourcePath string, indexPath utils.IndexPath)
|
||||
if !ok {
|
||||
return frontendRules, false
|
||||
}
|
||||
rule, ok := rulesBySource[ruleKey(indexPath)]
|
||||
rule, ok := rulesBySource[s.ruleKeyForNL(sourcePath, indexPath)]
|
||||
if !ok || rule == nil {
|
||||
return frontendRules, false
|
||||
}
|
||||
@@ -570,7 +608,7 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
|
||||
frontendPath := indexPath
|
||||
|
||||
// Check if path exists on filesystem
|
||||
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath))
|
||||
pathExists := checkRulePathExists(sourcePath, indexPath)
|
||||
|
||||
// Convert AccessRule to FrontendAccessRule
|
||||
frontendRules[frontendPath] = FrontendAccessRule{
|
||||
@@ -677,13 +715,8 @@ func (s *Storage) DeleteGroup(group string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
previousMembers, existed := s.Groups[group]
|
||||
// Snapshot the rules so they can be restored if the transaction fails.
|
||||
rulesSnapshot, err := json.Marshal(s.AllRules)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to snapshot access rules: %w", err)
|
||||
}
|
||||
delete(s.Groups, group)
|
||||
touched := s.removeAllRulesForGroupNL(group)
|
||||
touched, originals := s.removeAllRulesForGroupNL(group)
|
||||
if !existed && len(touched) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -691,11 +724,11 @@ func (s *Storage) DeleteGroup(group string) error {
|
||||
if existed {
|
||||
s.Groups[group] = previousMembers
|
||||
}
|
||||
restored := make(SourceRuleMap)
|
||||
if jsonErr := json.Unmarshal(rulesSnapshot, &restored); jsonErr != nil {
|
||||
logger.Errorf("failed to restore access rules after group delete error: %v", jsonErr)
|
||||
} else {
|
||||
s.AllRules = restored
|
||||
for key, rule := range originals {
|
||||
if s.AllRules[key.Source] == nil {
|
||||
s.AllRules[key.Source] = make(RuleMap)
|
||||
}
|
||||
s.AllRules[key.Source][key.Path] = rule
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -748,7 +781,7 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
|
||||
// Create a set of new groups for efficient lookup
|
||||
newGroupsSet := make(StringSet, len(newGroups))
|
||||
for _, g := range newGroups {
|
||||
if g == "" {
|
||||
if !IsUsableSyncedGroupName(g) {
|
||||
continue
|
||||
}
|
||||
newGroupsSet[g] = struct{}{}
|
||||
@@ -862,11 +895,148 @@ func (s *Storage) RemoveUserFromGroup(group, username string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUserFromAllGroups removes a username from every group's member list.
|
||||
// Group rows themselves are kept, even when left empty. Used when a user is deleted.
|
||||
func (s *Storage) RemoveUserFromAllGroups(username string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
affected := make(map[string]StringSet)
|
||||
for group, members := range s.Groups {
|
||||
if _, ok := members[username]; ok {
|
||||
affected[group] = cloneStringSet(members)
|
||||
}
|
||||
}
|
||||
if len(affected) == 0 {
|
||||
return nil
|
||||
}
|
||||
for group := range affected {
|
||||
delete(s.Groups[group], username)
|
||||
}
|
||||
var persisted []string
|
||||
for _, group := range slices.Sorted(maps.Keys(affected)) {
|
||||
if err := s.persistGroupSQLNL(group); err != nil {
|
||||
for _, g := range persisted {
|
||||
if restoreErr := s.restoreGroupSQLNL(g, affected[g]); restoreErr != nil {
|
||||
logger.Errorf("failed to restore group %q in sql after failure: %v", g, restoreErr)
|
||||
}
|
||||
}
|
||||
for g, snap := range affected {
|
||||
s.Groups[g] = snap
|
||||
}
|
||||
return err
|
||||
}
|
||||
persisted = append(persisted, group)
|
||||
}
|
||||
s.clearAllCaches()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameUserInGroups replaces one username with another in every group member list.
|
||||
// Used when a user's username changes so memberships follow the rename.
|
||||
func (s *Storage) RenameUserInGroups(oldName, newName string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
if oldName == newName {
|
||||
return nil
|
||||
}
|
||||
affected := make(map[string]StringSet)
|
||||
for group, members := range s.Groups {
|
||||
if _, ok := members[oldName]; ok {
|
||||
affected[group] = cloneStringSet(members)
|
||||
}
|
||||
}
|
||||
if len(affected) == 0 {
|
||||
return nil
|
||||
}
|
||||
for group := range affected {
|
||||
delete(s.Groups[group], oldName)
|
||||
s.Groups[group][newName] = struct{}{}
|
||||
}
|
||||
var persisted []string
|
||||
for _, group := range slices.Sorted(maps.Keys(affected)) {
|
||||
if err := s.persistGroupSQLNL(group); err != nil {
|
||||
for _, g := range persisted {
|
||||
if restoreErr := s.restoreGroupSQLNL(g, affected[g]); restoreErr != nil {
|
||||
logger.Errorf("failed to restore group %q in sql after failure: %v", g, restoreErr)
|
||||
}
|
||||
}
|
||||
for g, snap := range affected {
|
||||
s.Groups[g] = snap
|
||||
}
|
||||
return err
|
||||
}
|
||||
persisted = append(persisted, group)
|
||||
}
|
||||
s.clearAllCaches()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameUserInRules replaces one username with another in every allow and deny user list.
|
||||
// Used when a user's username changes so path access rules follow the rename.
|
||||
func (s *Storage) RenameUserInRules(oldName, newName string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
if oldName == newName {
|
||||
return nil
|
||||
}
|
||||
affected := make(map[RuleKey]*AccessRule)
|
||||
var keys []RuleKey
|
||||
for sourcePath, rulesBySource := range s.AllRules {
|
||||
for indexPath, rule := range rulesBySource {
|
||||
_, inAllow := rule.Allow.Users[oldName]
|
||||
_, inDeny := rule.Deny.Users[oldName]
|
||||
if !inAllow && !inDeny {
|
||||
continue
|
||||
}
|
||||
key := RuleKey{Source: sourcePath, Path: indexPath}
|
||||
affected[key] = cloneAccessRule(rule)
|
||||
if inAllow {
|
||||
delete(rule.Allow.Users, oldName)
|
||||
rule.Allow.Users[newName] = struct{}{}
|
||||
}
|
||||
if inDeny {
|
||||
delete(rule.Deny.Users, oldName)
|
||||
rule.Deny.Users[newName] = struct{}{}
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
slices.SortFunc(keys, func(a, b RuleKey) int {
|
||||
if c := strings.Compare(a.Source, b.Source); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.Path, b.Path)
|
||||
})
|
||||
var persisted []RuleKey
|
||||
for _, key := range keys {
|
||||
if err := s.persistRuleSQLErrorNL(key.Source, key.Path); err != nil {
|
||||
for _, pk := range persisted {
|
||||
if restoreErr := s.restoreRuleSQLNL(pk, affected[pk]); restoreErr != nil {
|
||||
logger.Errorf("failed to restore access rule %q/%q in sql after failure: %v", pk.Source, pk.Path, restoreErr)
|
||||
}
|
||||
}
|
||||
for key, snap := range affected {
|
||||
if s.AllRules[key.Source] == nil {
|
||||
s.AllRules[key.Source] = make(RuleMap)
|
||||
}
|
||||
s.AllRules[key.Source][key.Path] = snap
|
||||
}
|
||||
return err
|
||||
}
|
||||
persisted = append(persisted, key)
|
||||
}
|
||||
s.clearAllCaches()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveAllowUser removes a user from the allow list for a given source and index path.
|
||||
func (s *Storage) RemoveAllowUser(sourcePath string, indexPath utils.IndexPath, username string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -877,11 +1047,8 @@ func (s *Storage) RemoveAllowUser(sourcePath string, indexPath utils.IndexPath,
|
||||
}
|
||||
removed := exists
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
if !accessRuleHasPayload(rule) {
|
||||
s.deleteStoredRuleNL(sourcePath, normalizedPath)
|
||||
}
|
||||
if removed {
|
||||
s.clearAllCaches()
|
||||
@@ -895,7 +1062,7 @@ func (s *Storage) RemoveAllowUser(sourcePath string, indexPath utils.IndexPath,
|
||||
func (s *Storage) RemoveAllowGroup(sourcePath string, indexPath utils.IndexPath, groupname string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -906,11 +1073,8 @@ func (s *Storage) RemoveAllowGroup(sourcePath string, indexPath utils.IndexPath,
|
||||
}
|
||||
removed := exists
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
if !accessRuleHasPayload(rule) {
|
||||
s.deleteStoredRuleNL(sourcePath, normalizedPath)
|
||||
}
|
||||
if removed {
|
||||
s.clearAllCaches()
|
||||
@@ -924,7 +1088,7 @@ func (s *Storage) RemoveAllowGroup(sourcePath string, indexPath utils.IndexPath,
|
||||
func (s *Storage) RemoveDenyUser(sourcePath string, indexPath utils.IndexPath, username string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -935,11 +1099,8 @@ func (s *Storage) RemoveDenyUser(sourcePath string, indexPath utils.IndexPath, u
|
||||
}
|
||||
removed := exists
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
if !accessRuleHasPayload(rule) {
|
||||
s.deleteStoredRuleNL(sourcePath, normalizedPath)
|
||||
}
|
||||
if removed {
|
||||
s.clearAllCaches()
|
||||
@@ -953,7 +1114,7 @@ func (s *Storage) RemoveDenyUser(sourcePath string, indexPath utils.IndexPath, u
|
||||
func (s *Storage) RemoveDenyGroup(sourcePath string, indexPath utils.IndexPath, groupname string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -964,11 +1125,8 @@ func (s *Storage) RemoveDenyGroup(sourcePath string, indexPath utils.IndexPath,
|
||||
}
|
||||
removed := exists
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
if !accessRuleHasPayload(rule) {
|
||||
s.deleteStoredRuleNL(sourcePath, normalizedPath)
|
||||
}
|
||||
if removed {
|
||||
s.clearAllCaches()
|
||||
@@ -982,7 +1140,7 @@ func (s *Storage) RemoveDenyGroup(sourcePath string, indexPath utils.IndexPath,
|
||||
func (s *Storage) RemoveDenyAll(sourcePath string, indexPath utils.IndexPath) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -993,11 +1151,8 @@ func (s *Storage) RemoveDenyAll(sourcePath string, indexPath utils.IndexPath) (b
|
||||
removed = true
|
||||
}
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
if !accessRuleHasPayload(rule) {
|
||||
s.deleteStoredRuleNL(sourcePath, normalizedPath)
|
||||
}
|
||||
if removed {
|
||||
s.clearAllCaches()
|
||||
@@ -1008,93 +1163,98 @@ func (s *Storage) RemoveDenyAll(sourcePath string, indexPath utils.IndexPath) (b
|
||||
}
|
||||
|
||||
// RemoveAllRulesForUser removes a user from all allow and deny lists.
|
||||
// On a persistence failure the in-memory and already-persisted rules are restored and the error is returned.
|
||||
func (s *Storage) RemoveAllRulesForUser(username string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
changed := false
|
||||
dirty := make(map[string]map[string]struct{})
|
||||
touch := func(sp, ip string) {
|
||||
if dirty[sp] == nil {
|
||||
dirty[sp] = make(map[string]struct{})
|
||||
}
|
||||
dirty[sp][ip] = struct{}{}
|
||||
}
|
||||
affected := make(map[RuleKey]*AccessRule)
|
||||
for sourcePath, rulesBySource := range s.AllRules {
|
||||
for indexPath, rule := range rulesBySource {
|
||||
if _, exists := rule.Allow.Users[username]; exists {
|
||||
delete(rule.Allow.Users, username)
|
||||
touch(sourcePath, indexPath)
|
||||
changed = true
|
||||
_, inAllow := rule.Allow.Users[username]
|
||||
_, inDeny := rule.Deny.Users[username]
|
||||
if !inAllow && !inDeny {
|
||||
continue
|
||||
}
|
||||
if _, exists := rule.Deny.Users[username]; exists {
|
||||
delete(rule.Deny.Users, username)
|
||||
touch(sourcePath, indexPath)
|
||||
changed = true
|
||||
}
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], indexPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
affected[RuleKey{Source: sourcePath, Path: indexPath}] = cloneAccessRule(rule)
|
||||
delete(rule.Allow.Users, username)
|
||||
delete(rule.Deny.Users, username)
|
||||
}
|
||||
}
|
||||
if len(affected) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := slices.SortedFunc(maps.Keys(affected), func(a, b RuleKey) int {
|
||||
if c := strings.Compare(a.Source, b.Source); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.Path, b.Path)
|
||||
})
|
||||
for _, key := range keys {
|
||||
rule := s.AllRules[key.Source][key.Path]
|
||||
if rule.DenyAll || len(rule.Allow.Users) > 0 || len(rule.Allow.Groups) > 0 || len(rule.Deny.Users) > 0 || len(rule.Deny.Groups) > 0 {
|
||||
continue
|
||||
}
|
||||
s.deleteStoredRuleNL(key.Source, key.Path)
|
||||
}
|
||||
var persisted []RuleKey
|
||||
for _, key := range keys {
|
||||
if err := s.persistRuleSQLErrorNL(key.Source, key.Path); err != nil {
|
||||
for _, pk := range persisted {
|
||||
if restoreErr := s.restoreRuleSQLNL(pk, affected[pk]); restoreErr != nil {
|
||||
logger.Errorf("failed to restore access rule %q/%q in sql after failure: %v", pk.Source, pk.Path, restoreErr)
|
||||
}
|
||||
touch(sourcePath, indexPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
s.clearAllCaches()
|
||||
for sp, paths := range dirty {
|
||||
for ip := range paths {
|
||||
s.persistRuleSQLNL(sp, ip)
|
||||
for k, snap := range affected {
|
||||
if s.AllRules[k.Source] == nil {
|
||||
s.AllRules[k.Source] = make(RuleMap)
|
||||
}
|
||||
s.AllRules[k.Source][k.Path] = snap
|
||||
}
|
||||
return err
|
||||
}
|
||||
persisted = append(persisted, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveAllRulesForGroup removes a group from all allow and deny lists.
|
||||
func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
touched := s.removeAllRulesForGroupNL(groupname)
|
||||
if len(touched) > 0 {
|
||||
s.clearAllCaches()
|
||||
for _, key := range touched {
|
||||
s.persistRuleSQLNL(key.Source, key.Path)
|
||||
}
|
||||
}
|
||||
s.clearAllCaches()
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeAllRulesForGroupNL removes a group from every allow and deny list in memory and
|
||||
// returns the rules it touched (rules left empty are deleted). It does not persist; the
|
||||
// caller must hold s.mux and save the returned rules.
|
||||
func (s *Storage) removeAllRulesForGroupNL(groupname string) []RuleKey {
|
||||
// returns the rules it touched (rules left empty are deleted) plus a deep copy of each
|
||||
// touched rule's previous state for rollback. It does not persist; the caller must hold
|
||||
// s.mux and save the returned rules.
|
||||
func (s *Storage) removeAllRulesForGroupNL(groupname string) ([]RuleKey, map[RuleKey]*AccessRule) {
|
||||
var touched []RuleKey
|
||||
originals := make(map[RuleKey]*AccessRule)
|
||||
for sourcePath, rulesBySource := range s.AllRules {
|
||||
for indexPath, rule := range rulesBySource {
|
||||
touchedRule := false
|
||||
if _, exists := rule.Allow.Groups[groupname]; exists {
|
||||
delete(rule.Allow.Groups, groupname)
|
||||
touchedRule = true
|
||||
}
|
||||
if _, exists := rule.Deny.Groups[groupname]; exists {
|
||||
delete(rule.Deny.Groups, groupname)
|
||||
touchedRule = true
|
||||
_, inAllow := rule.Allow.Groups[groupname]
|
||||
_, inDeny := rule.Deny.Groups[groupname]
|
||||
if !inAllow && !inDeny {
|
||||
continue
|
||||
}
|
||||
key := RuleKey{Source: sourcePath, Path: indexPath}
|
||||
originals[key] = cloneAccessRule(rule)
|
||||
delete(rule.Allow.Groups, groupname)
|
||||
delete(rule.Deny.Groups, groupname)
|
||||
// A rule left with only DenyAll must stay, otherwise removing the group would open the path.
|
||||
if !accessRuleHasPayload(rule) {
|
||||
delete(s.AllRules[sourcePath], indexPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
touchedRule = true
|
||||
}
|
||||
if touchedRule {
|
||||
touched = append(touched, RuleKey{Source: sourcePath, Path: indexPath})
|
||||
s.deleteStoredRuleNL(sourcePath, indexPath)
|
||||
}
|
||||
touched = append(touched, key)
|
||||
}
|
||||
}
|
||||
return touched
|
||||
return touched, originals
|
||||
}
|
||||
|
||||
func cloneAccessRule(rule *AccessRule) *AccessRule {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
return &AccessRule{
|
||||
DenyAll: rule.DenyAll,
|
||||
Deny: RuleSet{Users: cloneStringSet(rule.Deny.Users), Groups: cloneStringSet(rule.Deny.Groups)},
|
||||
Allow: RuleSet{Users: cloneStringSet(rule.Allow.Users), Groups: cloneStringSet(rule.Allow.Groups)},
|
||||
}
|
||||
}
|
||||
|
||||
// GetRulesForUser returns all rules for a specific user for a given sourcePath.
|
||||
@@ -1320,7 +1480,7 @@ func (s *Storage) RemoveUserCascade(sourcePath string, indexPath utils.IndexPath
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil
|
||||
@@ -1356,7 +1516,7 @@ func (s *Storage) RemoveUserCascade(sourcePath string, indexPath utils.IndexPath
|
||||
// If rule is now empty, mark it for deletion
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
|
||||
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
|
||||
delete(s.AllRules[sourcePath], rulePath)
|
||||
s.deleteStoredRuleNL(sourcePath, rulePath)
|
||||
pathChanged = true
|
||||
}
|
||||
if pathChanged {
|
||||
@@ -1365,11 +1525,6 @@ func (s *Storage) RemoveUserCascade(sourcePath string, indexPath utils.IndexPath
|
||||
}
|
||||
}
|
||||
|
||||
// If no rules left for this source, remove the source entry
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
|
||||
if changed {
|
||||
s.clearAllCaches()
|
||||
for rp := range touchedPaths {
|
||||
@@ -1388,7 +1543,7 @@ func (s *Storage) RemoveGroupCascade(sourcePath string, indexPath utils.IndexPat
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil
|
||||
@@ -1424,7 +1579,7 @@ func (s *Storage) RemoveGroupCascade(sourcePath string, indexPath utils.IndexPat
|
||||
// If rule is now empty, mark it for deletion
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
|
||||
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
|
||||
delete(s.AllRules[sourcePath], rulePath)
|
||||
s.deleteStoredRuleNL(sourcePath, rulePath)
|
||||
pathChanged = true
|
||||
}
|
||||
if pathChanged {
|
||||
@@ -1433,11 +1588,6 @@ func (s *Storage) RemoveGroupCascade(sourcePath string, indexPath utils.IndexPat
|
||||
}
|
||||
}
|
||||
|
||||
// If no rules left for this source, remove the source entry
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
|
||||
if changed {
|
||||
s.clearAllCaches()
|
||||
for rp := range touchedPaths {
|
||||
@@ -1460,11 +1610,15 @@ func (s *Storage) UpdateRules(sourcePath string, oldPath, newPath utils.IndexPat
|
||||
return 0, nil // No rules for this source, not an error
|
||||
}
|
||||
|
||||
oldKey := ruleKey(oldPath)
|
||||
newKey := ruleKey(newPath)
|
||||
oldKey := s.ruleKeyForNL(sourcePath, oldPath)
|
||||
newKey := s.ruleKeyForNL(sourcePath, newPath)
|
||||
if oldKey == newKey {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
updated := 0
|
||||
rulesToUpdate := make(map[string]string) // old path -> new path
|
||||
movedRules := make(map[string]*AccessRule)
|
||||
|
||||
// Find all rules that need to be updated
|
||||
for rulePath := range rulesBySource {
|
||||
@@ -1476,11 +1630,20 @@ func (s *Storage) UpdateRules(sourcePath string, oldPath, newPath utils.IndexPat
|
||||
}
|
||||
}
|
||||
|
||||
// Update all matched rules
|
||||
for oldRulePath, newRulePath := range rulesToUpdate {
|
||||
rule := rulesBySource[oldRulePath]
|
||||
// Remove all matched rules first so overlapping moves cannot clobber each other
|
||||
for oldRulePath := range rulesToUpdate {
|
||||
movedRules[oldRulePath] = rulesBySource[oldRulePath]
|
||||
delete(rulesBySource, oldRulePath)
|
||||
rulesBySource[newRulePath] = rule
|
||||
}
|
||||
|
||||
// Insert or merge into target paths
|
||||
for oldRulePath, newRulePath := range rulesToUpdate {
|
||||
rule := movedRules[oldRulePath]
|
||||
if existing, exists := rulesBySource[newRulePath]; exists {
|
||||
mergeAccessRulesInPlace(existing, rule)
|
||||
} else {
|
||||
rulesBySource[newRulePath] = rule
|
||||
}
|
||||
logger.Info("access rule updated", "source", sourcePath, "fromPath", oldRulePath, "toPath", newRulePath)
|
||||
updated++
|
||||
}
|
||||
@@ -1488,7 +1651,7 @@ func (s *Storage) UpdateRules(sourcePath string, oldPath, newPath utils.IndexPat
|
||||
if updated > 0 {
|
||||
s.clearAllCaches()
|
||||
for oldRulePath := range rulesToUpdate {
|
||||
if s.sqlStore != nil {
|
||||
if s.sqlStore != nil && rulesBySource[oldRulePath] == nil {
|
||||
_ = s.sqlStore.DeleteAccessRule(sourcePath, oldRulePath)
|
||||
}
|
||||
}
|
||||
@@ -1505,8 +1668,8 @@ func (s *Storage) UpdateRulePath(sourcePath string, oldPath, newPath utils.Index
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
oldKey := ruleKey(oldPath)
|
||||
newKey := ruleKey(newPath)
|
||||
oldKey := s.ruleKeyForNL(sourcePath, oldPath)
|
||||
newKey := s.ruleKeyForNL(sourcePath, newPath)
|
||||
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
@@ -1517,9 +1680,16 @@ func (s *Storage) UpdateRulePath(sourcePath string, oldPath, newPath utils.Index
|
||||
if !ok {
|
||||
return fmt.Errorf("no rule found for path: %s", oldKey)
|
||||
}
|
||||
if oldKey == newKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(rulesBySource, oldKey)
|
||||
rulesBySource[newKey] = rule
|
||||
if existing, exists := rulesBySource[newKey]; exists {
|
||||
mergeAccessRulesInPlace(existing, rule)
|
||||
} else {
|
||||
rulesBySource[newKey] = rule
|
||||
}
|
||||
s.clearAllCaches()
|
||||
if s.sqlStore != nil {
|
||||
_ = s.sqlStore.DeleteAccessRule(sourcePath, oldKey)
|
||||
|
||||
@@ -16,6 +16,11 @@ type stubGroupSQL struct {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -43,6 +48,11 @@ type recordingGroupSQL struct {
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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,34 +6,35 @@ import "fmt"
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
EventDuplicateFinder EventType = "duplicateFinder"
|
||||
)
|
||||
|
||||
@@ -44,6 +45,7 @@ var AllEventTypes = []EventType{
|
||||
EventCopy,
|
||||
EventRename,
|
||||
EventUpload,
|
||||
EventFileModify,
|
||||
EventDelete,
|
||||
EventBulkDelete,
|
||||
EventArchive,
|
||||
@@ -77,6 +79,7 @@ var FileEventTypes = []EventType{
|
||||
EventCopy,
|
||||
EventRename,
|
||||
EventUpload,
|
||||
EventFileModify,
|
||||
EventDelete,
|
||||
EventBulkDelete,
|
||||
EventArchive,
|
||||
@@ -155,7 +158,7 @@ func intersectEventTypes(requested, allowed []EventType) []EventType {
|
||||
func (e EventType) Valid() bool {
|
||||
switch e {
|
||||
case EventDownload, EventMove, EventCopy, EventRename,
|
||||
EventUpload, EventDelete, EventBulkDelete,
|
||||
EventUpload, EventFileModify, EventDelete, EventBulkDelete,
|
||||
EventArchive, EventUnarchive,
|
||||
EventShareCreate, EventShareUpdate, EventShareDelete,
|
||||
EventUserCreate, EventUserUpdate, EventUserDelete, EventAccessUpdate, EventAccessCreate, EventAccessDelete,
|
||||
|
||||
@@ -21,6 +21,9 @@ 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"`
|
||||
|
||||
@@ -145,15 +145,20 @@ 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)
|
||||
|
||||
pos := strings.Index(l.Path, oldPath)
|
||||
if pos < 0 {
|
||||
norm := utils.AddTrailingSlashIfNotExists(l.Path)
|
||||
if !strings.HasPrefix(norm, oldPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
l.SourcePath = newSource
|
||||
l.Path = newPath
|
||||
suffix := strings.TrimPrefix(norm, oldPath)
|
||||
l.Path = strings.TrimSuffix(newPath+suffix, "/")
|
||||
if shareWasDir {
|
||||
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
|
||||
}
|
||||
|
||||
if err := s.back.Save(l); err != nil {
|
||||
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
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, ¬Null, &defaultValue, &pk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name == "created_time" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -245,6 +245,7 @@ func (db *IndexDB) CreateIndexTable() error {
|
||||
parent_path TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL DEFAULT 0,
|
||||
mod_time INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
is_dir BOOLEAN NOT NULL,
|
||||
@@ -259,17 +260,63 @@ func (db *IndexDB) CreateIndexTable() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_last_updated ON index_items(source, last_updated);
|
||||
`
|
||||
_, err := db.Exec(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.ensureCreatedTimeColumn()
|
||||
}
|
||||
|
||||
// ensureCreatedTimeColumn adds the created_time column to index_items when it is
|
||||
// missing, so databases created before the column existed are upgraded in place.
|
||||
func (db *IndexDB) ensureCreatedTimeColumn() error {
|
||||
rows, err := db.Query(`PRAGMA table_info(index_items)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid, notNull, pk int
|
||||
var name, columnType string
|
||||
var defaultValue interface{}
|
||||
scanErr := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &pk)
|
||||
if scanErr != nil {
|
||||
rows.Close()
|
||||
return scanErr
|
||||
}
|
||||
if name == "created_time" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if rowsErr := rows.Err(); rowsErr != nil {
|
||||
rows.Close()
|
||||
return rowsErr
|
||||
}
|
||||
rows.Close()
|
||||
if found {
|
||||
return nil
|
||||
}
|
||||
_, err = db.Exec(`ALTER TABLE index_items ADD COLUMN created_time INTEGER NOT NULL DEFAULT 0`)
|
||||
return err
|
||||
}
|
||||
|
||||
// createdUnix converts a creation time to unix seconds for storage, returning 0
|
||||
// when the time is nil or zero (stored as "unavailable").
|
||||
func createdUnix(created *time.Time) int64 {
|
||||
if created == nil || created.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return created.Unix()
|
||||
}
|
||||
|
||||
func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) error {
|
||||
query := `
|
||||
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO index_items (source, path, parent_path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(source, path) DO UPDATE SET
|
||||
parent_path = excluded.parent_path,
|
||||
name = excluded.name,
|
||||
size = excluded.size,
|
||||
created_time = excluded.created_time,
|
||||
mod_time = excluded.mod_time,
|
||||
type = excluded.type,
|
||||
is_dir = excluded.is_dir,
|
||||
@@ -284,6 +331,7 @@ func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) erro
|
||||
parentPath,
|
||||
info.Name,
|
||||
info.Size,
|
||||
createdUnix(info.Created),
|
||||
info.ModTime.Unix(),
|
||||
info.Type,
|
||||
info.Type == "directory",
|
||||
@@ -365,12 +413,13 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
|
||||
}()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO index_items (source, path, parent_path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(source, path) DO UPDATE SET
|
||||
parent_path = excluded.parent_path,
|
||||
name = excluded.name,
|
||||
size = excluded.size,
|
||||
created_time = excluded.created_time,
|
||||
mod_time = excluded.mod_time,
|
||||
type = excluded.type,
|
||||
is_dir = excluded.is_dir,
|
||||
@@ -396,6 +445,7 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
|
||||
parentPath,
|
||||
info.Name,
|
||||
info.Size,
|
||||
createdUnix(info.Created),
|
||||
info.ModTime.Unix(),
|
||||
info.Type,
|
||||
info.Type == "directory",
|
||||
@@ -428,7 +478,7 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
|
||||
// Busy/locked errors are returned so callers can fall back to the filesystem.
|
||||
func (db *IndexDB) GetItem(source, path string) (*iteminfo.FileInfo, error) {
|
||||
query := `
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items WHERE source = ? AND path = ?
|
||||
`
|
||||
row := db.QueryRow(query, source, path)
|
||||
@@ -456,7 +506,7 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items WHERE source = ? AND path IN (%s)
|
||||
`, strings.Join(placeholders, ","))
|
||||
|
||||
@@ -481,7 +531,7 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
|
||||
// GetDirectoryChildren retrieves all children of a directory for a specific source.
|
||||
func (db *IndexDB) GetDirectoryChildren(source, dirPath string) ([]*iteminfo.FileInfo, error) {
|
||||
query := `
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items WHERE source = ? AND parent_path = ?
|
||||
ORDER BY is_dir DESC, name ASC
|
||||
`
|
||||
@@ -927,7 +977,7 @@ func (db *IndexDB) GetFilesForMultipleSizes(source string, sizes []int64, pathPr
|
||||
if pathPrefix != "" {
|
||||
nextPrefix := getNextPathPrefix(pathPrefix)
|
||||
query = fmt.Sprintf(`
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ? AND size IN (%s) AND is_dir = 0 AND path >= ? AND path < ?
|
||||
ORDER BY size, type, name
|
||||
@@ -935,7 +985,7 @@ func (db *IndexDB) GetFilesForMultipleSizes(source string, sizes []int64, pathPr
|
||||
args = append(args, pathPrefix, nextPrefix)
|
||||
} else {
|
||||
query = fmt.Sprintf(`
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ? AND size IN (%s) AND is_dir = 0
|
||||
ORDER BY size, type, name
|
||||
@@ -1095,7 +1145,7 @@ func (db *IndexDB) GetFilesBySizeAndType(source string, size int64, fileType str
|
||||
if pathPrefix != "" {
|
||||
nextPrefix := getNextPathPrefix(pathPrefix)
|
||||
query = `
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ? AND size = ? AND type = ? AND is_dir = 0 AND path >= ? AND path < ?
|
||||
ORDER BY name
|
||||
@@ -1103,7 +1153,7 @@ func (db *IndexDB) GetFilesBySizeAndType(source string, size int64, fileType str
|
||||
args = []interface{}{source, size, fileType, pathPrefix, nextPrefix}
|
||||
} else {
|
||||
query = `
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ? AND size = ? AND type = ? AND is_dir = 0
|
||||
ORDER BY name
|
||||
@@ -1137,7 +1187,7 @@ func (db *IndexDB) GetFilesBySize(source string, size int64, pathPrefix string)
|
||||
if pathPrefix != "" {
|
||||
nextPrefix := getNextPathPrefix(pathPrefix)
|
||||
query = `
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ? AND size = ? AND is_dir = 0 AND path >= ? AND path < ?
|
||||
ORDER BY name
|
||||
@@ -1145,7 +1195,7 @@ func (db *IndexDB) GetFilesBySize(source string, size int64, pathPrefix string)
|
||||
args = []interface{}{source, size, pathPrefix, nextPrefix}
|
||||
} else {
|
||||
query = `
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ? AND size = ? AND is_dir = 0
|
||||
ORDER BY name
|
||||
@@ -1260,6 +1310,7 @@ func getParentPath(path string) string {
|
||||
|
||||
func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileInfo, error) {
|
||||
var info iteminfo.FileInfo
|
||||
var createdTime int64
|
||||
var modTime int64
|
||||
var isDir bool
|
||||
|
||||
@@ -1267,6 +1318,7 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
|
||||
&info.Path,
|
||||
&info.Name,
|
||||
&info.Size,
|
||||
&createdTime,
|
||||
&modTime,
|
||||
&info.Type,
|
||||
&isDir,
|
||||
@@ -1276,6 +1328,10 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if createdTime > 0 {
|
||||
t := time.Unix(createdTime, 0)
|
||||
info.Created = &t
|
||||
}
|
||||
info.ModTime = time.Unix(modTime, 0)
|
||||
info.IsDir = isDir
|
||||
return &info, nil
|
||||
|
||||
@@ -6,11 +6,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testutil.QuietLogging()
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0o644, 0o755)
|
||||
}
|
||||
|
||||
@@ -8,14 +8,15 @@ import (
|
||||
|
||||
// SearchItem represents a single search result row from the database
|
||||
type SearchItem struct {
|
||||
Source string
|
||||
Path string
|
||||
Name string
|
||||
Size int64
|
||||
ModTime int64
|
||||
Type string
|
||||
IsDir bool
|
||||
HasPreview bool
|
||||
Source string
|
||||
Path string
|
||||
Name string
|
||||
Size int64
|
||||
CreatedTime int64
|
||||
ModTime int64
|
||||
Type string
|
||||
IsDir bool
|
||||
HasPreview bool
|
||||
}
|
||||
|
||||
// appendNameGlobSQL adds AND (name GLOB ? OR ...) or AND (name GLOB ? AND ...) when joinAnd is true.
|
||||
@@ -44,8 +45,8 @@ func appendNameGlobSQL(query string, args []interface{}, patterns []string, join
|
||||
// Returns rows that can be iterated to scan search results.
|
||||
func (db *IndexDB) SearchItems(source string, scope string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd, caseExact bool) (*sql.Rows, error) {
|
||||
query := `
|
||||
SELECT path, name, size, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ?
|
||||
`
|
||||
args := []interface{}{source}
|
||||
@@ -77,8 +78,8 @@ func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[str
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT source, path, name, size, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
SELECT source, path, name, size, created_time, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
`
|
||||
args := []interface{}{}
|
||||
whereClauses := []string{}
|
||||
|
||||
@@ -3,11 +3,32 @@ package sqldb
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
)
|
||||
|
||||
// settingAccessRulesLowercaseMigrated marks that access rule paths were normalized to lowercase.
|
||||
const settingAccessRulesLowercaseMigrated = "access_rules_lowercase_migrated"
|
||||
|
||||
// AccessRulesLowercaseMigrated reports whether the one-time lowercase migration has completed.
|
||||
func (s *SQLStore) AccessRulesLowercaseMigrated() (bool, error) {
|
||||
_, err := s.GetSetting(settingAccessRulesLowercaseMigrated)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SetAccessRulesLowercaseMigrated records that the lowercase migration finished (empty DBs too).
|
||||
func (s *SQLStore) SetAccessRulesLowercaseMigrated() error {
|
||||
return s.SaveSetting(settingAccessRulesLowercaseMigrated, true)
|
||||
}
|
||||
|
||||
// Access rules SQL operations
|
||||
|
||||
// GetAccessRulesBySource retrieves all access rules for a source
|
||||
@@ -121,6 +142,42 @@ func (s *SQLStore) SaveAccessRule(source, path string, rule *access.AccessRule)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateAccessRulePathsLowercase rewrites rule paths to lowercase in one transaction.
|
||||
func (s *SQLStore) MigrateAccessRulePathsLowercase(source string, oldPaths []string, newRules access.RuleMap) error {
|
||||
tx, err := s.BeginTx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin access rule migration: %w", err)
|
||||
}
|
||||
rollback := func(cause error) error {
|
||||
if rbErr := tx.Rollback(); rbErr != nil {
|
||||
return fmt.Errorf("%w (rollback also failed: %v)", cause, rbErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
for path, rule := range newRules {
|
||||
ruleDataJSON, err := json.Marshal(rule)
|
||||
if err != nil {
|
||||
return rollback(fmt.Errorf("failed to marshal rule data: %w", err))
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT OR REPLACE INTO access_rules (source, path, rule_data) VALUES (?, ?, ?)`, source, path, ruleDataJSON); err != nil {
|
||||
return rollback(fmt.Errorf("failed to save access rule: %w", err))
|
||||
}
|
||||
}
|
||||
for _, old := range oldPaths {
|
||||
if _, kept := newRules[old]; kept {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM access_rules WHERE source = ? AND path = ?`, source, old); err != nil {
|
||||
return rollback(fmt.Errorf("failed to delete access rule: %w", err))
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit access rule migration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAccessRule deletes an access rule
|
||||
func (s *SQLStore) DeleteAccessRule(source, path string) error {
|
||||
query := `DELETE FROM access_rules WHERE source = ? AND path = ?`
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
)
|
||||
|
||||
// The lowercase marker is set by StartupMigrateAccessRuleKeys after all
|
||||
// sources migrate, not inside the per-source migration transaction.
|
||||
func TestMigrateAccessRulePathsLowercaseRewritesPaths(t *testing.T) {
|
||||
store, _, err := NewSQLStore(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
rule := &access.AccessRule{
|
||||
Allow: access.RuleSet{Users: map[string]struct{}{"bob": {}}},
|
||||
}
|
||||
if err = store.SaveAccessRule("src", "/Old/", rule); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newRules := access.RuleMap{
|
||||
"/old/": rule,
|
||||
}
|
||||
if err = store.MigrateAccessRulePathsLowercase("src", []string{"/Old/"}, newRules); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rules, err := store.GetAccessRulesBySource("src")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rules["/old/"] == nil {
|
||||
t.Fatal("expected lowercased rule row")
|
||||
}
|
||||
if rules["/Old/"] != nil {
|
||||
t.Fatal("expected old rule path to be deleted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GlobalPermissionsFieldMask records which global permission JSON keys were present
|
||||
// on a create-user request (admin, api, share, realtime). Omitted keys should receive
|
||||
// userDefaults; present keys keep the caller's value, including explicit false.
|
||||
type GlobalPermissionsFieldMask struct {
|
||||
Admin bool
|
||||
Api bool
|
||||
Share bool
|
||||
Realtime bool
|
||||
}
|
||||
|
||||
// Any reports whether at least one global permission key was explicitly present.
|
||||
func (m GlobalPermissionsFieldMask) Any() bool {
|
||||
return m.Admin || m.Api || m.Share || m.Realtime
|
||||
}
|
||||
|
||||
// GlobalPermissionsFieldMaskFromJSON builds a mask from a permissions object JSON value.
|
||||
// A nil or JSON null value yields an empty mask (no keys explicitly set).
|
||||
func GlobalPermissionsFieldMaskFromJSON(raw json.RawMessage) GlobalPermissionsFieldMask {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return GlobalPermissionsFieldMask{}
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return GlobalPermissionsFieldMask{}
|
||||
}
|
||||
var mask GlobalPermissionsFieldMask
|
||||
for key := range obj {
|
||||
switch strings.ToLower(key) {
|
||||
case "admin":
|
||||
mask.Admin = true
|
||||
case "api":
|
||||
mask.Api = true
|
||||
case "share":
|
||||
mask.Share = true
|
||||
case "realtime":
|
||||
mask.Realtime = true
|
||||
}
|
||||
}
|
||||
return mask
|
||||
}
|
||||
|
||||
// MergeGlobalPermissionsOnCreate combines defaults with caller-supplied global permissions.
|
||||
// When mask is empty, any true global flag on requested preserves the full requested struct
|
||||
// (programmatic create paths). Otherwise only masked fields override defaults.
|
||||
func MergeGlobalPermissionsOnCreate(defaults, requested Permissions, mask GlobalPermissionsFieldMask) Permissions {
|
||||
if !mask.Any() {
|
||||
if requested.Admin || requested.Api || requested.Share || requested.Realtime {
|
||||
return requested
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
out := defaults
|
||||
if mask.Admin {
|
||||
out.Admin = requested.Admin
|
||||
}
|
||||
if mask.Api {
|
||||
out.Api = requested.Api
|
||||
}
|
||||
if mask.Share {
|
||||
out.Share = requested.Share
|
||||
}
|
||||
if mask.Realtime {
|
||||
out.Realtime = requested.Realtime
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGlobalPermissionsFieldMaskFromJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want GlobalPermissionsFieldMask
|
||||
}{
|
||||
{name: "empty object", raw: `{}`, want: GlobalPermissionsFieldMask{}},
|
||||
{name: "null", raw: `null`, want: GlobalPermissionsFieldMask{}},
|
||||
{name: "admin only", raw: `{"admin":true}`, want: GlobalPermissionsFieldMask{Admin: true}},
|
||||
{name: "partial mixed case", raw: `{"Share":false,"api":true}`, want: GlobalPermissionsFieldMask{Api: true, Share: true}},
|
||||
{name: "explicit false still present", raw: `{"share":false}`, want: GlobalPermissionsFieldMask{Share: true}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := GlobalPermissionsFieldMaskFromJSON(json.RawMessage(tc.raw))
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %+v, want %+v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGlobalPermissionsOnCreate(t *testing.T) {
|
||||
defaults := Permissions{Admin: false, Api: true, Share: true, Realtime: true}
|
||||
|
||||
t.Run("omitted permissions keep defaults", func(t *testing.T) {
|
||||
requested := Permissions{}
|
||||
got := MergeGlobalPermissionsOnCreate(defaults, requested, GlobalPermissionsFieldMask{})
|
||||
if got != defaults {
|
||||
t.Fatalf("got %+v, want defaults %+v", got, defaults)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial mask merges only present keys", func(t *testing.T) {
|
||||
requested := Permissions{Admin: true, Api: false}
|
||||
mask := GlobalPermissionsFieldMask{Admin: true, Api: true}
|
||||
got := MergeGlobalPermissionsOnCreate(defaults, requested, mask)
|
||||
want := Permissions{Admin: true, Api: false, Share: true, Realtime: true}
|
||||
if got != want {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit false on masked field", func(t *testing.T) {
|
||||
requested := Permissions{Share: false}
|
||||
mask := GlobalPermissionsFieldMask{Share: true}
|
||||
got := MergeGlobalPermissionsOnCreate(defaults, requested, mask)
|
||||
want := Permissions{Admin: false, Api: true, Share: false, Realtime: true}
|
||||
if got != want {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("programmatic all true without mask", func(t *testing.T) {
|
||||
requested := Permissions{Admin: true, Api: true, Share: true, Realtime: true}
|
||||
got := MergeGlobalPermissionsOnCreate(defaults, requested, GlobalPermissionsFieldMask{})
|
||||
if got != requested {
|
||||
t.Fatalf("got %+v, want %+v", got, requested)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,68 @@
|
||||
package users
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPermissionsUnmarshalJSONMarksConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var p Permissions
|
||||
if err := json.Unmarshal([]byte(`{"admin":false,"share":false,"api":false,"realtime":false}`), &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.Configured {
|
||||
t.Fatal("expected Configured after unmarshaling an explicit permissions object")
|
||||
}
|
||||
if p.Admin || p.Share || p.Api || p.Realtime {
|
||||
t.Fatalf("expected all-false permissions, got %+v", p)
|
||||
}
|
||||
|
||||
var u struct {
|
||||
Permissions Permissions `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{}`), &u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Permissions.Configured {
|
||||
t.Fatal("omitted permissions must not set Configured")
|
||||
}
|
||||
|
||||
var nullWrap struct {
|
||||
Permissions Permissions `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{"permissions":null}`), &nullWrap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nullWrap.Permissions.Configured {
|
||||
t.Fatal("permissions null must not set Configured")
|
||||
}
|
||||
if nullWrap.Permissions.GlobalPermissionsFieldMaskFromPermissions().Any() {
|
||||
t.Fatal("permissions null must not set global key mask")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionsUnmarshalJSONPartialKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var u struct {
|
||||
Permissions Permissions `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{"permissions":{"admin":true}}`), &u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !u.Permissions.Configured {
|
||||
t.Fatal("expected Configured for explicit permissions object")
|
||||
}
|
||||
mask := u.Permissions.GlobalPermissionsFieldMaskFromPermissions()
|
||||
if !mask.Admin || mask.Api || mask.Share || mask.Realtime {
|
||||
t.Fatalf("mask = %+v, want admin only", mask)
|
||||
}
|
||||
if !u.Permissions.Admin {
|
||||
t.Fatal("expected admin true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceFilePermissionsIsUnset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -11,8 +11,14 @@ const (
|
||||
SidebarLinkSourceAlt SidebarLinkCategory = "source-alt"
|
||||
SidebarLinkSourceHybrid SidebarLinkCategory = "source-hybrid"
|
||||
SidebarLinkSourceHybrid2 SidebarLinkCategory = "source-hybrid-2"
|
||||
SidebarLinkTool SidebarLinkCategory = "tool"
|
||||
SidebarLinkCustom SidebarLinkCategory = "custom"
|
||||
// "-root" variants display the root-filesystem-only disk usage (no nested
|
||||
// mount aggregation) instead of the aggregated multi-mount totals.
|
||||
SidebarLinkSourceRoot SidebarLinkCategory = "source-root"
|
||||
SidebarLinkSourceAltRoot SidebarLinkCategory = "source-alt-root"
|
||||
SidebarLinkSourceHybridRoot SidebarLinkCategory = "source-hybrid-root"
|
||||
SidebarLinkSourceHybrid2Root SidebarLinkCategory = "source-hybrid-2-root"
|
||||
SidebarLinkTool SidebarLinkCategory = "tool"
|
||||
SidebarLinkCustom SidebarLinkCategory = "custom"
|
||||
)
|
||||
|
||||
// NormalizeSidebarLinkCategory returns a known category string, preserving source-* variants.
|
||||
@@ -23,7 +29,10 @@ func NormalizeSidebarLinkCategory(category string) string {
|
||||
}
|
||||
switch SidebarLinkCategory(c) {
|
||||
case SidebarLinkSource, SidebarLinkSourceMinimal, SidebarLinkSourceAlt,
|
||||
SidebarLinkSourceHybrid, SidebarLinkSourceHybrid2, SidebarLinkTool, SidebarLinkCustom:
|
||||
SidebarLinkSourceHybrid, SidebarLinkSourceHybrid2,
|
||||
SidebarLinkSourceRoot, SidebarLinkSourceAltRoot,
|
||||
SidebarLinkSourceHybridRoot, SidebarLinkSourceHybrid2Root,
|
||||
SidebarLinkTool, SidebarLinkCustom:
|
||||
return c
|
||||
}
|
||||
if strings.HasPrefix(c, "source") {
|
||||
@@ -36,3 +45,14 @@ func NormalizeSidebarLinkCategory(category string) string {
|
||||
func IsSourceSidebarCategory(category string) bool {
|
||||
return strings.HasPrefix(NormalizeSidebarLinkCategory(category), "source")
|
||||
}
|
||||
|
||||
// SidebarLinkDiskScopeRoot reports whether the category shows the
|
||||
// root-filesystem-only disk usage rather than the aggregated nested-mount usage.
|
||||
func SidebarLinkDiskScopeRoot(category string) bool {
|
||||
return strings.HasSuffix(NormalizeSidebarLinkCategory(category), "-root")
|
||||
}
|
||||
|
||||
// BaseSidebarLinkCategory strips the root-only "-root" suffix from a category.
|
||||
func BaseSidebarLinkCategory(category string) string {
|
||||
return strings.TrimSuffix(NormalizeSidebarLinkCategory(category), "-root")
|
||||
}
|
||||
@@ -24,7 +24,11 @@ func HasAnyGlobalPermission(p Permissions) bool {
|
||||
}
|
||||
|
||||
// IsMinimalApiToken reports whether the token inherits the owner's globals at auth time
|
||||
// rather than embedding its own global permission caps in the JWT.
|
||||
// rather than applying stored global permission caps.
|
||||
func IsMinimalApiToken(t AuthToken) bool {
|
||||
if t.Minimal != nil {
|
||||
return *t.Minimal
|
||||
}
|
||||
// Legacy tokens lack minimal; empty stored global caps implied minimal (WebDAV-style).
|
||||
return !HasAnyGlobalPermission(t.Permissions)
|
||||
}
|
||||
@@ -45,6 +45,14 @@ func TestIsMinimalApiToken(t *testing.T) {
|
||||
t.Fatal("custom token with global caps should not be minimal")
|
||||
}
|
||||
if !IsMinimalApiToken(AuthToken{Permissions: Permissions{Modify: true}}) {
|
||||
t.Fatal("token without global caps should be minimal")
|
||||
t.Fatal("legacy token without global caps should be minimal")
|
||||
}
|
||||
minimal := true
|
||||
if !IsMinimalApiToken(AuthToken{Minimal: &minimal}) {
|
||||
t.Fatal("explicit minimal token should be minimal")
|
||||
}
|
||||
customized := false
|
||||
if IsMinimalApiToken(AuthToken{Minimal: &customized}) {
|
||||
t.Fatal("explicit customized token with empty caps should not be minimal")
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,16 @@ func TestValidateUsernameAcceptsNormalName(t *testing.T) {
|
||||
t.Fatalf("expected valid username, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUsernameRejectsUnsafeNames(t *testing.T) {
|
||||
for _, username := range []string{"..", "....", ".", "-", "al..ice", "bob/", "./bob", `bob\`, "alice/docs", " alice", "alice "} {
|
||||
if err := ValidateUsername(username); err == nil {
|
||||
t.Fatalf("expected %q to be rejected", username)
|
||||
}
|
||||
}
|
||||
for _, username := range []string{"alice", "first.last", "j.r.r", "a-b_c", "user@example.com", "José", "李雷", ".alice", "users"} {
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
t.Fatalf("expected %q to be accepted, got %v", username, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -47,8 +48,10 @@ var reservedUsernames = map[string]struct{}{
|
||||
strings.ToLower(AnonymousUserName): {},
|
||||
}
|
||||
|
||||
// ValidateUsername rejects empty and reserved login names.
|
||||
// ValidateUsername rejects empty and reserved login names, and names that do not map to their
|
||||
// own createUserDir home directory.
|
||||
func ValidateUsername(username string) error {
|
||||
raw := username
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return fmt.Errorf("username is empty")
|
||||
@@ -56,6 +59,11 @@ func ValidateUsername(username string) error {
|
||||
if _, reserved := reservedUsernames[strings.ToLower(username)]; reserved {
|
||||
return fmt.Errorf("username %q is reserved", username)
|
||||
}
|
||||
// The per-user home directory is derived from CleanUsername(username), so reject names
|
||||
// that it would rewrite, the names "." and "-", and names containing path separators.
|
||||
if CleanUsername(raw) != raw || raw == "." || raw == "-" || strings.ContainsAny(raw, `/\`) {
|
||||
return fmt.Errorf("username %q contains characters that are not allowed", raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -97,6 +105,8 @@ type AuthToken struct {
|
||||
BelongsTo uint64 `json:"belongsTo,omitempty"` // legacy stored metadata only, never trusted for auth; identity is always the hashed_tokens lookup
|
||||
IssuedAt int64 `json:"issuedAt,omitempty"`
|
||||
ExpiresAt int64 `json:"expiresAt,omitempty"`
|
||||
// Minimal distinguishes owner-inheriting tokens from customized caps. Nil means legacy (infer from stored permissions).
|
||||
Minimal *bool `json:"minimal,omitempty"`
|
||||
Permissions Permissions `json:"Permissions,omitempty"`
|
||||
}
|
||||
|
||||
@@ -106,15 +116,65 @@ type MinimalAuthToken struct {
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Api bool `json:"api"` // allow api access
|
||||
Admin bool `json:"admin"` // allow admin access
|
||||
Modify bool `json:"modify,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Share bool `json:"share"` // allow sharing files
|
||||
Realtime bool `json:"realtime"` // allow realtime updates
|
||||
Delete bool `json:"delete,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Create bool `json:"create,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Download bool `json:"download,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
View bool `json:"view,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Api bool `json:"api"` // allow api access
|
||||
Admin bool `json:"admin"` // allow admin access
|
||||
Modify bool `json:"modify,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Share bool `json:"share"` // allow sharing files
|
||||
Realtime bool `json:"realtime"` // allow realtime updates
|
||||
Delete bool `json:"delete,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Create bool `json:"create,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Download bool `json:"download,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
View bool `json:"view,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Configured bool `json:"-"` // true when explicitly set on create (allows intentional deny-all); not persisted or API-visible
|
||||
|
||||
PresentGlobalKeys GlobalPermissionsFieldMask `json:"-"` // JSON keys present on create-user permissions object
|
||||
}
|
||||
|
||||
// MarkPermissionsConfigured marks account permissions as explicitly set (including all-false).
|
||||
func MarkPermissionsConfigured(p Permissions) Permissions {
|
||||
p.Configured = true
|
||||
return p
|
||||
}
|
||||
|
||||
// GlobalPermissionsFieldMaskFromPermissions returns keys recorded during Permissions JSON decode.
|
||||
func (p Permissions) GlobalPermissionsFieldMaskFromPermissions() GlobalPermissionsFieldMask {
|
||||
return p.PresentGlobalKeys
|
||||
}
|
||||
|
||||
// UnmarshalJSON sets Configured so an explicit all-false permissions object is distinct from omission.
|
||||
// JSON null and absent permissions do not set Configured.
|
||||
func (p *Permissions) UnmarshalJSON(data []byte) error {
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
*p = Permissions{}
|
||||
return nil
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
return err
|
||||
}
|
||||
type plain Permissions
|
||||
var tmp plain
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
*p = Permissions(tmp)
|
||||
p.Configured = true
|
||||
p.PresentGlobalKeys = GlobalPermissionsFieldMaskFromJSON(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EffectiveGlobalPermissionsMask combines the caller-supplied field mask and the key
|
||||
// presence recorded by Permissions.UnmarshalJSON.
|
||||
func (u *User) EffectiveGlobalPermissionsMask() GlobalPermissionsFieldMask {
|
||||
fromPerms := u.Permissions.GlobalPermissionsFieldMaskFromPermissions()
|
||||
m := u.PermissionsFieldMask
|
||||
return GlobalPermissionsFieldMask{
|
||||
Admin: m.Admin || fromPerms.Admin,
|
||||
Api: m.Api || fromPerms.Api,
|
||||
Share: m.Share || fromPerms.Share,
|
||||
Realtime: m.Realtime || fromPerms.Realtime,
|
||||
}
|
||||
}
|
||||
|
||||
// SourceFilePermissions holds per-source file operation permissions (v4+).
|
||||
@@ -173,17 +233,18 @@ type Preview struct {
|
||||
// FrontendUser holds fields safe to return from user APIs (embedded on User).
|
||||
type FrontendUser struct {
|
||||
NonAdminEditable
|
||||
DisableSettings bool `json:"disableSettings"`
|
||||
Username string `json:"username"`
|
||||
FrontendScopes []FrontendScope `json:"scopes"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
|
||||
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
|
||||
LoginMethod LoginMethod `json:"loginMethod"`
|
||||
OtpEnabled bool `json:"otpEnabled"`
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
EffectiveToolAccess map[string]bool `json:"effectiveToolAccess,omitempty"`
|
||||
Perm Permissions `json:"perm,omitzero"`
|
||||
DisableSettings bool `json:"disableSettings"`
|
||||
Username string `json:"username"`
|
||||
FrontendScopes []FrontendScope `json:"scopes"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
RequirePasswordChange bool `json:"requirePasswordChange"`
|
||||
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
|
||||
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
|
||||
LoginMethod LoginMethod `json:"loginMethod"`
|
||||
OtpEnabled bool `json:"otpEnabled"`
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
EffectiveToolAccess map[string]bool `json:"effectiveToolAccess,omitempty"`
|
||||
Perm Permissions `json:"perm,omitzero"`
|
||||
}
|
||||
|
||||
// PinnedItems maps source filesystem path -> index directory path -> pinned item names.
|
||||
@@ -198,6 +259,10 @@ type UserLegacyFields struct {
|
||||
type User struct {
|
||||
FrontendUser
|
||||
ID uint64 `json:"id,omitempty"`
|
||||
// PermissionsFieldMask marks global permission keys a programmatic create caller
|
||||
// explicitly supplied; not persisted. JSON create requests record key presence on
|
||||
// Permissions.PresentGlobalKeys during decode instead.
|
||||
PermissionsFieldMask GlobalPermissionsFieldMask `json:"-"`
|
||||
// BackendScopes is the authoritative, persisted access list (SourceScope.Name = backend source path).
|
||||
// SQLite stores this inside user_data JSON under the key "scopes" (see sqldb.UserData).
|
||||
BackendScopes []BackendScope `json:"backendScopes,omitempty"`
|
||||
@@ -234,7 +299,6 @@ type BackendScope struct {
|
||||
|
||||
// json tags must match variable name with smaller case first letter
|
||||
type NonAdminEditable struct {
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
@@ -263,6 +327,8 @@ type NonAdminEditable struct {
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
|
||||
ShowTypeColumn bool `json:"showTypeColumn"` // show the Type column in list and compact views
|
||||
ShowCreationDateColumn bool `json:"showCreationDateColumn"` // show the Creation date column in list and compact views
|
||||
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
|
||||
@@ -18,7 +18,8 @@ var (
|
||||
ErrAccessDenied = errors.New("access denied")
|
||||
ErrInvalidRequestParams = errors.New("invalid request params")
|
||||
ErrSourceIsParent = errors.New("source is parent")
|
||||
ErrNoTotpProvided = errors.New("OTP code is required for user")
|
||||
ErrNoTotpProvided = errors.New("OTP code is required for user")
|
||||
ErrPasswordChangeRequired = errors.New("password change is required for user")
|
||||
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not configured")
|
||||
ErrUnauthorized = errors.New("user unauthorized")
|
||||
ErrNotIndexed = errors.New("directory or item excluded from indexing")
|
||||
|
||||
@@ -41,15 +41,13 @@ func handleUserEvents() {
|
||||
for ue := range userEventChan {
|
||||
for _, user := range ue.users {
|
||||
userClientsMu.RLock()
|
||||
conns := userClients[user]
|
||||
userClientsMu.RUnlock()
|
||||
|
||||
for _, ch := range conns {
|
||||
for _, ch := range userClients[user] {
|
||||
select {
|
||||
case ch <- ue.event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
userClientsMu.RUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,28 +133,14 @@ func DebouncedBroadcast(eventType, message string) {
|
||||
func handleSourceUpdates() {
|
||||
for update := range sourceUpdateChan {
|
||||
sourceClientsMu.RLock()
|
||||
clients := sourceClients[update.source]
|
||||
clientCount := len(clients)
|
||||
sourceClientsMu.RUnlock()
|
||||
|
||||
if clientCount == 0 {
|
||||
// No clients registered for this source - this is normal if no one is connected
|
||||
continue
|
||||
}
|
||||
|
||||
sentCount := 0
|
||||
for ch := range clients {
|
||||
for ch := range sourceClients[update.source] {
|
||||
select {
|
||||
case ch <- update.event:
|
||||
sentCount++
|
||||
default:
|
||||
// Channel full, message dropped
|
||||
}
|
||||
}
|
||||
// Log if we have clients but couldn't send to all
|
||||
//if sentCount < clientCount {
|
||||
// // Some messages were dropped due to full channels
|
||||
//}
|
||||
sourceClientsMu.RUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConcurrentRegisterUnregisterSourceUpdates(t *testing.T) {
|
||||
stop := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
ch := Register("stress-user", []string{"stress-source"})
|
||||
Unregister("stress-user", ch)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < 200; i++ {
|
||||
SendSourceUpdate("stress-source", "tick")
|
||||
}
|
||||
close(stop)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestConcurrentSSEClientLifecycleRace(t *testing.T) {
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
user := "race-user-" + string(rune('a'+id))
|
||||
source := "race-source-" + string(rune('a'+id%2))
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
ch := Register(user, []string{source, "shared-source"})
|
||||
SendSourceUpdate(source, "tick")
|
||||
SendSourceUpdate("shared-source", "tick")
|
||||
SendToUsers("ping", "msg", []string{user})
|
||||
Unregister(user, ch)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
SendSourceUpdate("shared-source", "broadcast")
|
||||
SendToUsers("broadcast", "all", []string{"race-user-a", "race-user-b"})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
Shutdown()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
@@ -209,7 +209,7 @@ func generateMaskableIcon(sourceData []byte, icon iconSize) error {
|
||||
// Android adaptive-icon content ratio (72/108 ≈ 2/3) keeps square art inside every mask shape
|
||||
inner := icon.size * 2 / 3
|
||||
art := imaging.Fit(img, inner, inner, imaging.Lanczos)
|
||||
canvas := imaging.PasteCenter(imaging.New(icon.size, icon.size, maskableBackground(img)), art)
|
||||
canvas := imaging.OverlayCenter(imaging.New(icon.size, icon.size, maskableBackground(img)), art, 1)
|
||||
|
||||
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
|
||||
outFile, err := os.Create(outputPath)
|
||||
|
||||
@@ -1,13 +1,94 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
func TestGenerateMaskableIconKeepsCanvasOpaque(t *testing.T) {
|
||||
originalCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = t.TempDir()
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCacheDir })
|
||||
if err := os.MkdirAll(settings.PWAIconsCacheDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
background color.Color
|
||||
source func(*image.RGBA)
|
||||
}{
|
||||
{
|
||||
name: "transparent padding",
|
||||
background: color.White,
|
||||
source: func(img *image.RGBA) {
|
||||
img.Set(5, 5, color.RGBA{R: 20, G: 40, B: 200, A: 255})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "opaque edge",
|
||||
background: color.NRGBA{R: 200, G: 100, B: 50, A: 255},
|
||||
source: func(img *image.RGBA) {
|
||||
for x := 0; x < 12; x++ {
|
||||
img.Set(x, 0, color.RGBA{R: 200, G: 100, B: 50, A: 255})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
source := image.NewRGBA(image.Rect(0, 0, 12, 12))
|
||||
tt.source(source)
|
||||
var sourceData bytes.Buffer
|
||||
if err := png.Encode(&sourceData, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const size = 96
|
||||
icon := iconSize{size: size, name: "maskable.png", maskable: true}
|
||||
if err := generateMaskableIcon(sourceData.Bytes(), icon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, err := imaging.Open(filepath.Join(settings.PWAIconsCacheDir(), icon.name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if output.Bounds().Dx() != size || output.Bounds().Dy() != size {
|
||||
t.Fatalf("got bounds %v, want %dx%d", output.Bounds(), size, size)
|
||||
}
|
||||
want, ok := color.NRGBAModel.Convert(tt.background).(color.NRGBA)
|
||||
if !ok {
|
||||
t.Fatalf("background color %#v is not NRGBA", tt.background)
|
||||
}
|
||||
for y := 0; y < size; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
outsideArt := x < 16 || x >= 80 || y < 16 || y >= 80
|
||||
got, ok := color.NRGBAModel.Convert(output.At(x, y)).(color.NRGBA)
|
||||
if !ok {
|
||||
t.Fatalf("pixel (%d,%d) is not NRGBA", x, y)
|
||||
}
|
||||
if got.A != 255 {
|
||||
t.Fatalf("pixel (%d,%d) has alpha %d, want opaque", x, y, got.A)
|
||||
}
|
||||
if outsideArt && got != want {
|
||||
t.Fatalf("pixel (%d,%d) got %#v, want background %#v", x, y, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskableBackgroundOpaqueCorner(t *testing.T) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
|
||||
img.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
|
||||
|
||||
@@ -78,12 +78,10 @@ func extractTIFFEmbeddedPreview(f *os.File) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ex, err := extimagemeta.Decode(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var candidates []previewCandidate
|
||||
if ex, err := extimagemeta.Decode(f); err == nil {
|
||||
candidates = previewCandidates(ex)
|
||||
}
|
||||
|
||||
candidates := previewCandidates(ex)
|
||||
if scanCandidates, scanErr := collectTIFFPreviewCandidates(f); scanErr == nil {
|
||||
candidates = append(candidates, scanCandidates...)
|
||||
}
|
||||
@@ -115,6 +113,9 @@ func previewCandidates(ex exif.Exif) []previewCandidate {
|
||||
out = append(out, previewCandidate{offset: ifd.ImageOffset, length: ifd.ImageLength})
|
||||
}
|
||||
}
|
||||
for _, sub := range ex.SubIFDs {
|
||||
appendIFD(sub)
|
||||
}
|
||||
appendIFD(ex.IFD1)
|
||||
appendIFD(ex.IFD2)
|
||||
|
||||
|
||||
@@ -47,15 +47,16 @@ func GetOrientation(ctx context.Context, path string) string {
|
||||
default:
|
||||
ex, err = extimagemeta.Decode(f)
|
||||
}
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if ex.IFD0.Orientation == 0 {
|
||||
return ""
|
||||
if err == nil && ex.IFD0.Orientation != 0 {
|
||||
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(ex.IFD0.Orientation))
|
||||
}
|
||||
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(ex.IFD0.Orientation))
|
||||
if !isHEICExtension(ext) {
|
||||
if orient := extractTIFFOrientation(f); orient != 0 {
|
||||
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(orient))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -9,11 +9,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
tiffTagStripOffsets = 0x0111
|
||||
tiffTagOrientation = 0x0112
|
||||
tiffTagStripByteCounts = 0x0117
|
||||
tiffTagSubIFDs = 0x014a
|
||||
tiffTagJpgFromRaw = 0x002e
|
||||
tiffTagJPEGInterchange = 0x0201
|
||||
tiffTagJPEGInterchangeLen = 0x0202
|
||||
tiffIFDScanLimit = 4 * 1024 * 1024
|
||||
tiffMagicStandard = 42
|
||||
tiffMagicPanasonicRW2 = 0x0055
|
||||
tiffMagicOlympusORF1 = 0x4f52
|
||||
tiffMagicOlympusORF2 = 0x5352
|
||||
tiffTypeByte = 1
|
||||
tiffTypeASCII = 2
|
||||
tiffTypeShort = 3
|
||||
@@ -36,30 +43,47 @@ type tiffTag struct {
|
||||
valueOffset uint32
|
||||
}
|
||||
|
||||
// collectTIFFPreviewCandidates walks TIFF IFDs (including SubIFDs) for embedded
|
||||
// JPEG preview offset/length pairs that imagemeta does not surface.
|
||||
func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
|
||||
// readTIFFScanWindow reads the bounded IFD scan window (up to tiffIFDScanLimit bytes) of f.
|
||||
// It validates the 8-byte TIFF header first, so non-TIFF files such as JPEGs are rejected
|
||||
// without allocating or reading the full window. ok is false for non-TIFF or too-short files.
|
||||
func readTIFFScanWindow(f *os.File) (header []byte, order binary.ByteOrder, ifd0 uint32, ok bool, err error) {
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, 0, false, err
|
||||
}
|
||||
scanSize := info.Size()
|
||||
if scanSize > tiffIFDScanLimit {
|
||||
scanSize = tiffIFDScanLimit
|
||||
}
|
||||
if scanSize < 8 {
|
||||
return nil, nil
|
||||
return nil, nil, 0, false, nil
|
||||
}
|
||||
header := make([]byte, scanSize)
|
||||
var magic [8]byte
|
||||
if _, err = f.ReadAt(magic[:], 0); err != nil {
|
||||
if err == io.EOF {
|
||||
return nil, nil, 0, false, nil
|
||||
}
|
||||
return nil, nil, 0, false, err
|
||||
}
|
||||
if _, _, ok = tiffHeaderIFD0(magic[:]); !ok {
|
||||
return nil, nil, 0, false, nil
|
||||
}
|
||||
header = make([]byte, scanSize)
|
||||
n, err := f.ReadAt(header, 0)
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
return nil, nil, 0, false, err
|
||||
}
|
||||
header = header[:n]
|
||||
order, ifd0, ok = tiffHeaderIFD0(header)
|
||||
return header, order, ifd0, ok, nil
|
||||
}
|
||||
|
||||
order, ifd0, ok := tiffHeaderIFD0(header)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
// collectTIFFPreviewCandidates walks TIFF IFDs (including SubIFDs) for embedded
|
||||
// JPEG preview offset/length pairs that imagemeta does not surface.
|
||||
func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
|
||||
header, order, ifd0, ok, err := readTIFFScanWindow(f)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[uint32]struct{})
|
||||
@@ -97,6 +121,7 @@ func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
|
||||
for _, ifd := range ifds {
|
||||
out = appendPreviewCandidates(out, jpgFromRawCandidates(ifd))
|
||||
out = appendPreviewCandidates(out, jpegInterchangeCandidates(ifd))
|
||||
out = appendPreviewCandidates(out, stripPreviewCandidates(ifd, order))
|
||||
if len(out) >= maxTIFFPreviewCandidates {
|
||||
break
|
||||
}
|
||||
@@ -104,6 +129,24 @@ func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// extractTIFFOrientation reads standard TIFF IFD0 Orientation (0x0112) as a fallback
|
||||
// when full EXIF decoding fails.
|
||||
func extractTIFFOrientation(f *os.File) uint16 {
|
||||
header, order, ifd0, ok, err := readTIFFScanWindow(f)
|
||||
if err != nil || !ok || ifd0 == 0 {
|
||||
return 0
|
||||
}
|
||||
ifd, _, _, err := parseTIFFIFD(header, order, ifd0)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
tag, ok := ifd.tags[tiffTagOrientation]
|
||||
if !ok || tag.count == 0 {
|
||||
return 0
|
||||
}
|
||||
return tiffTagFirstUint16(tag, order)
|
||||
}
|
||||
|
||||
func appendPreviewCandidates(dst, src []previewCandidate) []previewCandidate {
|
||||
remaining := maxTIFFPreviewCandidates - len(dst)
|
||||
if remaining <= 0 {
|
||||
@@ -144,6 +187,48 @@ func jpegInterchangeCandidates(ifd tiffIFD) []previewCandidate {
|
||||
return []previewCandidate{{offset: offset, length: length}}
|
||||
}
|
||||
|
||||
func stripPreviewCandidates(ifd tiffIFD, order binary.ByteOrder) []previewCandidate {
|
||||
offTag, okOff := ifd.tags[tiffTagStripOffsets]
|
||||
lenTag, okLen := ifd.tags[tiffTagStripByteCounts]
|
||||
if !okOff || !okLen || offTag.count != 1 || lenTag.count != 1 {
|
||||
return nil
|
||||
}
|
||||
offset := tiffTagScalarUint32(offTag, order)
|
||||
length := tiffTagScalarUint32(lenTag, order)
|
||||
if offset == 0 || length <= 4 || length > maxPreviewReadSize {
|
||||
return nil
|
||||
}
|
||||
return []previewCandidate{{offset: offset, length: length}}
|
||||
}
|
||||
|
||||
func tiffTagScalarUint32(t tiffTag, order binary.ByteOrder) uint32 {
|
||||
switch t.typ {
|
||||
case tiffTypeLong, tiffTypeIFD:
|
||||
return t.valueOffset
|
||||
case tiffTypeShort:
|
||||
return uint32(tiffTagFirstUint16(t, order))
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func tiffTagFirstUint16(t tiffTag, order binary.ByteOrder) uint16 {
|
||||
switch t.typ {
|
||||
case tiffTypeShort:
|
||||
if order == binary.BigEndian {
|
||||
return uint16(t.valueOffset >> 16)
|
||||
}
|
||||
return uint16(t.valueOffset & 0xffff)
|
||||
case tiffTypeLong:
|
||||
if t.valueOffset > 0xffff {
|
||||
return 0
|
||||
}
|
||||
return uint16(t.valueOffset)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func tiffHeaderIFD0(data []byte) (binary.ByteOrder, uint32, bool) {
|
||||
if len(data) < 8 {
|
||||
return nil, 0, false
|
||||
@@ -157,7 +242,9 @@ func tiffHeaderIFD0(data []byte) (binary.ByteOrder, uint32, bool) {
|
||||
default:
|
||||
return nil, 0, false
|
||||
}
|
||||
if order.Uint16(data[2:4]) != 42 {
|
||||
switch order.Uint16(data[2:4]) {
|
||||
case tiffMagicStandard, tiffMagicPanasonicRW2, tiffMagicOlympusORF1, tiffMagicOlympusORF2:
|
||||
default:
|
||||
return nil, 0, false
|
||||
}
|
||||
return order, order.Uint32(data[4:8]), true
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestCollectTIFFPreviewCandidatesIgnoresMalformedOffsets(t *testing.T) {
|
||||
func TestCollectTIFFPreviewCandidatesIgnoresMalformedSubIFDOffset(t *testing.T) {
|
||||
data := buildSyntheticTIFF(tiffFixture{
|
||||
thumbnailJPEG: smallJPEG,
|
||||
subIFDJPEG: largeJPEG,
|
||||
subIFDJPEG: largeJPEG,
|
||||
badJpgFromRaw: true,
|
||||
})
|
||||
f := openBytesFile(t, data)
|
||||
@@ -114,6 +114,138 @@ func TestTiffTagUint32sSupportsIFDType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmbeddedPreviewFallbackWhenDecodeFails(t *testing.T) {
|
||||
// Synthetic ARW where SubIFD offset is placed after IFD2, and a SubIFD tag
|
||||
// points past EOF so extimagemeta.Decode fails with EOF, while IFD2 holds a
|
||||
// valid JPEGInterchangeFormat preview.
|
||||
order := binary.LittleEndian
|
||||
buf := make([]byte, 32*1024)
|
||||
copy(buf[0:2], []byte("II"))
|
||||
order.PutUint16(buf[2:4], tiffMagicStandard)
|
||||
ifd0Off := uint32(8)
|
||||
order.PutUint32(buf[4:8], ifd0Off)
|
||||
|
||||
thumbOff := uint32(4096)
|
||||
copy(buf[thumbOff:], smallJPEG)
|
||||
largeOff := uint32(8192)
|
||||
copy(buf[largeOff:], largeJPEG)
|
||||
|
||||
ifd1Off := uint32(256)
|
||||
ifd2Off := uint32(512)
|
||||
subIFDOff := uint32(768)
|
||||
|
||||
writeIFD := func(offset uint32, entries []tiffEntry, next uint32) {
|
||||
off := int(offset)
|
||||
order.PutUint16(buf[off:off+2], uint16(len(entries)))
|
||||
entryBase := off + 2
|
||||
for i, entry := range entries {
|
||||
pos := entryBase + i*12
|
||||
order.PutUint16(buf[pos:pos+2], entry.tag)
|
||||
order.PutUint16(buf[pos+2:pos+4], entry.typ)
|
||||
order.PutUint32(buf[pos+4:pos+8], entry.count)
|
||||
order.PutUint32(buf[pos+8:pos+12], entry.value)
|
||||
}
|
||||
order.PutUint32(buf[entryBase+len(entries)*12:entryBase+len(entries)*12+4], next)
|
||||
}
|
||||
|
||||
writeIFD(ifd0Off, []tiffEntry{
|
||||
{tag: tiffTagOrientation, typ: tiffTypeShort, count: 1, value: 6},
|
||||
{tag: tiffTagSubIFDs, typ: tiffTypeLong, count: 1, value: subIFDOff},
|
||||
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
|
||||
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(smallJPEG))},
|
||||
}, ifd1Off)
|
||||
|
||||
writeIFD(ifd1Off, []tiffEntry{
|
||||
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
|
||||
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(smallJPEG))},
|
||||
}, ifd2Off)
|
||||
|
||||
writeIFD(ifd2Off, []tiffEntry{
|
||||
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: largeOff},
|
||||
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(largeJPEG))},
|
||||
}, 0)
|
||||
|
||||
// SubIFD has a StripOffsets tag with count=2 pointing beyond file end so Decode fails with EOF.
|
||||
writeIFD(subIFDOff, []tiffEntry{
|
||||
{tag: tiffTagStripOffsets, typ: tiffTypeLong, count: 2, value: uint32(len(buf) + 4096)},
|
||||
}, 0)
|
||||
|
||||
path := filepath.Join(t.TempDir(), "sample.arw")
|
||||
if err := os.WriteFile(path, buf[:int(largeOff)+len(largeJPEG)], 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
preview, err := ExtractEmbeddedPreview(t.Context(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractEmbeddedPreview() err = %v", err)
|
||||
}
|
||||
if len(preview) != len(largeJPEG) {
|
||||
t.Fatalf("ExtractEmbeddedPreview() len = %d, want %d", len(preview), len(largeJPEG))
|
||||
}
|
||||
if got := GetOrientation(t.Context(), path); got != "Rotate 90 CW" {
|
||||
t.Fatalf("GetOrientation() = %q, want %q", got, "Rotate 90 CW")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmbeddedPreviewSupportsRW2AndStripPreview(t *testing.T) {
|
||||
order := binary.LittleEndian
|
||||
buf := make([]byte, 16*1024)
|
||||
copy(buf[0:2], []byte("II"))
|
||||
order.PutUint16(buf[2:4], tiffMagicPanasonicRW2)
|
||||
ifd0Off := uint32(8)
|
||||
order.PutUint32(buf[4:8], ifd0Off)
|
||||
|
||||
largeOff := uint32(4096)
|
||||
copy(buf[largeOff:], largeJPEG)
|
||||
|
||||
entries := []tiffEntry{
|
||||
{tag: tiffTagStripOffsets, typ: tiffTypeLong, count: 1, value: largeOff},
|
||||
{tag: tiffTagStripByteCounts, typ: tiffTypeLong, count: 1, value: uint32(len(largeJPEG))},
|
||||
}
|
||||
order.PutUint16(buf[ifd0Off:ifd0Off+2], uint16(len(entries)))
|
||||
for i, entry := range entries {
|
||||
pos := int(ifd0Off) + 2 + i*12
|
||||
order.PutUint16(buf[pos:pos+2], entry.tag)
|
||||
order.PutUint16(buf[pos+2:pos+4], entry.typ)
|
||||
order.PutUint32(buf[pos+4:pos+8], entry.count)
|
||||
order.PutUint32(buf[pos+8:pos+12], entry.value)
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "sample.rw2")
|
||||
if err := os.WriteFile(path, buf[:int(largeOff)+len(largeJPEG)], 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
preview, err := ExtractEmbeddedPreview(t.Context(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractEmbeddedPreview() err = %v", err)
|
||||
}
|
||||
if len(preview) != len(largeJPEG) {
|
||||
t.Fatalf("ExtractEmbeddedPreview() len = %d, want %d", len(preview), len(largeJPEG))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmbeddedPreviewDSC02672(t *testing.T) {
|
||||
path := "/Users/steffag/Downloads/DSC02672-first-4MB.ARW"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skip(path)
|
||||
}
|
||||
|
||||
got, err := ExtractEmbeddedPreview(t.Context(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsJPEG(got) {
|
||||
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
|
||||
}
|
||||
if len(got) != 2954147 {
|
||||
t.Fatalf("preview size = %d, want 2954147", len(got))
|
||||
}
|
||||
if orient := GetOrientation(t.Context(), path); orient != "Horizontal (normal)" {
|
||||
t.Fatalf("GetOrientation() = %q, want Horizontal (normal)", orient)
|
||||
}
|
||||
}
|
||||
|
||||
type tiffFixture struct {
|
||||
thumbnailJPEG []byte
|
||||
subIFDJPEG []byte
|
||||
@@ -195,8 +327,8 @@ func buildSyntheticTIFF(fix tiffFixture) []byte {
|
||||
}
|
||||
|
||||
type tiffEntry struct {
|
||||
tag, typ uint16
|
||||
count, value uint32
|
||||
tag, typ uint16
|
||||
count, value uint32
|
||||
}
|
||||
|
||||
func buildChainedTIFF(chainLen int) []byte {
|
||||
@@ -218,3 +350,22 @@ func buildChainedTIFF(chainLen int) []byte {
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
// Non-TIFF files (e.g. a JPEG reaching the orientation fallback) must be rejected from the
|
||||
// 8-byte header, not after reading the whole scan window.
|
||||
func TestReadTIFFScanWindowRejectsNonTIFFFromHeader(t *testing.T) {
|
||||
jpeg := append([]byte{0xff, 0xd8, 0xff, 0xe0}, make([]byte, 1<<20)...)
|
||||
header, _, _, ok, err := readTIFFScanWindow(openBytesFile(t, jpeg))
|
||||
if err != nil || ok || header != nil {
|
||||
t.Fatalf("got header=%d bytes ok=%v err=%v, want nil/false/nil", len(header), ok, err)
|
||||
}
|
||||
if got := extractTIFFOrientation(openBytesFile(t, jpeg)); got != 0 {
|
||||
t.Fatalf("orientation = %d, want 0", got)
|
||||
}
|
||||
|
||||
tiff := buildSyntheticTIFF(tiffFixture{thumbnailJPEG: smallJPEG})
|
||||
header, _, ifd0, ok, err := readTIFFScanWindow(openBytesFile(t, tiff))
|
||||
if err != nil || !ok || ifd0 == 0 || len(header) != len(tiff) {
|
||||
t.Fatalf("tiff: header=%d ok=%v ifd0=%d err=%v", len(header), ok, ifd0, err)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,18 @@ func (x Format) toImaging() imaging.Format {
|
||||
}
|
||||
}
|
||||
|
||||
// encodable reports whether imaging can encode this format.
|
||||
// imaging only encodes JPEG, PNG, GIF, TIFF and BMP; webp and the netpbm
|
||||
// formats are decode-only, so previews for them must be written as JPEG.
|
||||
func (x Format) encodable() bool {
|
||||
switch x {
|
||||
case FormatJpeg, FormatPng, FormatGif, FormatTiff, FormatBmp:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
ENUM(
|
||||
high
|
||||
@@ -203,7 +215,9 @@ func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
|
||||
var wrappedReader = in
|
||||
var err error
|
||||
|
||||
if opts.Format == FormatHeic {
|
||||
// HEIC has no encoder and webp/netpbm are decode-only in imaging; fall back
|
||||
// to JPEG so preview generation does not fail with ErrUnsupportedFormat.
|
||||
if !opts.Format.encodable() {
|
||||
opts.Format = FormatJpeg
|
||||
}
|
||||
// Ensure JPEG quality is set based on quality setting
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/image/bmp"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testutil.QuietLogging()
|
||||
// Ensure fileutils permissions are set (needed by NewPreviewGenerator)
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
@@ -159,6 +161,14 @@ func TestService_Resize(t *testing.T) {
|
||||
},
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize webp falls back to jpeg": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Format: FormatWebp},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return openFile(t, "testdata/gray-sample.webp")
|
||||
},
|
||||
matcher: formatMatcher(FormatJpeg),
|
||||
},
|
||||
"resize with high quality": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityHigh},
|
||||
source: func(t *testing.T) afero.File {
|
||||
|
||||
@@ -272,15 +272,6 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended
|
||||
case previewTypeImage:
|
||||
ext := strings.ToLower(filepath.Ext(file.Name))
|
||||
if iteminfo.IsRawImage(ext) {
|
||||
if s.ffmpegService != nil {
|
||||
previewBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
|
||||
if err == nil && len(previewBytes) >= minPreviewSize {
|
||||
return previewBytes, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
return nil, ErrUnsupportedFormat
|
||||
}
|
||||
return s.generateImagePreview(ctx, file, previewSize)
|
||||
|
||||
@@ -21,6 +21,8 @@ type UploadContext struct {
|
||||
SessionID string
|
||||
TotalSize int64
|
||||
HasKnownSize bool
|
||||
PriorFileSize int64
|
||||
HasPriorFileSize bool
|
||||
}
|
||||
|
||||
// CheckUploadLength verifies size is known when quotas apply.
|
||||
@@ -90,7 +92,9 @@ func uploadCommitDelta(ctx UploadContext) int64 {
|
||||
return 0
|
||||
}
|
||||
oldSize := int64(0)
|
||||
if ctx.DestRealPath != "" {
|
||||
if ctx.HasPriorFileSize {
|
||||
oldSize = ctx.PriorFileSize
|
||||
} else if ctx.DestRealPath != "" {
|
||||
if stat, err := os.Stat(ctx.DestRealPath); err == nil && !stat.IsDir() {
|
||||
oldSize = stat.Size()
|
||||
}
|
||||
|
||||
@@ -1,49 +1,27 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
import "testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestUploadCommitDelta_shrinkReturnsNegative(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dest := filepath.Join(dir, "file.bin")
|
||||
if err := os.WriteFile(dest, make([]byte, 100), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func TestUploadCommitDeltaTreatsMissingDestAsZeroPrior(t *testing.T) {
|
||||
ctx := UploadContext{
|
||||
Principal: &users.User{},
|
||||
DestRealPath: dest,
|
||||
TotalSize: 50,
|
||||
HasKnownSize: true,
|
||||
TotalSize: 50,
|
||||
HasKnownSize: true,
|
||||
HasPriorFileSize: true,
|
||||
PriorFileSize: 0,
|
||||
}
|
||||
if got := UploadCommitDelta(ctx); got != -50 {
|
||||
t.Fatalf("UploadCommitDelta shrink: got %d want -50", got)
|
||||
}
|
||||
if got := uploadReserveDelta(ctx); got != 0 {
|
||||
t.Fatalf("uploadReserveDelta shrink: got %d want 0", got)
|
||||
if delta := uploadCommitDelta(ctx); delta != 50 {
|
||||
t.Fatalf("delta = %d, want 50", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadCommitDelta_growthPositive(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dest := filepath.Join(dir, "file.bin")
|
||||
if err := os.WriteFile(dest, make([]byte, 40), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func TestUploadCommitDeltaUsesPriorFileSize(t *testing.T) {
|
||||
ctx := UploadContext{
|
||||
Principal: &users.User{},
|
||||
DestRealPath: dest,
|
||||
TotalSize: 100,
|
||||
HasKnownSize: true,
|
||||
TotalSize: 100,
|
||||
HasKnownSize: true,
|
||||
HasPriorFileSize: true,
|
||||
PriorFileSize: 40,
|
||||
}
|
||||
if got := UploadCommitDelta(ctx); got != 60 {
|
||||
t.Fatalf("UploadCommitDelta growth: got %d want 60", got)
|
||||
}
|
||||
if got := uploadReserveDelta(ctx); got != 60 {
|
||||
t.Fatalf("uploadReserveDelta growth: got %d want 60", got)
|
||||
if delta := uploadCommitDelta(ctx); delta != 60 {
|
||||
t.Fatalf("delta = %d, want 60", delta)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
@@ -102,6 +103,46 @@ func SyncUserGroups(username string, newGroups []string) error {
|
||||
return accessDb.SyncUserGroups(username, newGroups)
|
||||
}
|
||||
|
||||
// GroupExists reports whether a group name is already registered.
|
||||
func GroupExists(groupname string) bool {
|
||||
if accessDb == nil {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(accessDb.GetAllGroups(), groupname)
|
||||
}
|
||||
|
||||
// RemoveUserFromAllGroups removes a username from every group's member list.
|
||||
func RemoveUserFromAllGroups(username string) error {
|
||||
if accessDb == nil {
|
||||
return nil
|
||||
}
|
||||
return accessDb.RemoveUserFromAllGroups(username)
|
||||
}
|
||||
|
||||
// RenameUserInGroups replaces one username with another in all group member lists.
|
||||
func RenameUserInGroups(oldName, newName string) error {
|
||||
if accessDb == nil {
|
||||
return nil
|
||||
}
|
||||
return accessDb.RenameUserInGroups(oldName, newName)
|
||||
}
|
||||
|
||||
// RenameUserInRules replaces one username with another in all path access allow/deny lists.
|
||||
func RenameUserInRules(oldName, newName string) error {
|
||||
if accessDb == nil {
|
||||
return nil
|
||||
}
|
||||
return accessDb.RenameUserInRules(oldName, newName)
|
||||
}
|
||||
|
||||
// RemoveAllRulesForUser removes a username from every path access allow and deny list.
|
||||
func RemoveAllRulesForUser(username string) error {
|
||||
if accessDb == nil {
|
||||
return nil
|
||||
}
|
||||
return accessDb.RemoveAllRulesForUser(username)
|
||||
}
|
||||
|
||||
// SetAccessSQLStoreForTest replaces the SQL persister used by access control (tests only).
|
||||
func SetAccessSQLStoreForTest(store access.SQLPersister) {
|
||||
if accessDb != nil {
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
const bootstrapAdminPasswordHexBytes = 6 // 12 hex characters
|
||||
|
||||
// bootstrapDefaultAdminPassword returns the plaintext password for initial admin creation.
|
||||
// Uses auth.methods.password.adminPassword (backfilled from auth.adminPassword when unset).
|
||||
// Blank or "admin" is ignored and a random password is generated and logged once.
|
||||
// When config adminPassword is blank or "admin", a random speakable password is generated and logged once.
|
||||
func bootstrapDefaultAdminPassword(username string) (plaintext string, generated bool, err error) {
|
||||
cfg := settings.PasswordAdminPassword()
|
||||
if cfg != "" && cfg != "admin" {
|
||||
return cfg, false, nil
|
||||
}
|
||||
|
||||
plaintext, err = utils.RandomHex(bootstrapAdminPasswordHexBytes)
|
||||
plaintext, err = generateSpeakableBootstrapPassword()
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("generate bootstrap admin password: %w", err)
|
||||
}
|
||||
@@ -30,3 +29,37 @@ func bootstrapDefaultAdminPassword(username string) (plaintext string, generated
|
||||
)
|
||||
return plaintext, true, nil
|
||||
}
|
||||
|
||||
func generateSpeakableBootstrapPassword() (string, error) {
|
||||
if len(bootstrapWords) == 0 {
|
||||
return "", fmt.Errorf("bootstrap word list is empty")
|
||||
}
|
||||
wordIdx, err := rand.Int(rand.Reader, big.NewInt(int64(len(bootstrapWords))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
word := bootstrapWords[wordIdx.Int64()]
|
||||
|
||||
primary, err := randomSpeakableCode(bootstrapSpeakablePrimaryCodeLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secondary, err := randomSpeakableCode(bootstrapSpeakableSecondaryCodeLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s-%s-%s", word, string(primary), string(secondary)), nil
|
||||
}
|
||||
|
||||
func randomSpeakableCode(n int) ([]byte, error) {
|
||||
codeRunes := make([]byte, n)
|
||||
charsetLen := big.NewInt(int64(len(bootstrapSpeakableCharset)))
|
||||
for i := range codeRunes {
|
||||
idx, err := rand.Int(rand.Reader, charsetLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
codeRunes[i] = bootstrapSpeakableCharset[idx.Int64()]
|
||||
}
|
||||
return codeRunes, nil
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestBootstrapWordsCount(t *testing.T) {
|
||||
if len(bootstrapWords) != 100 {
|
||||
t.Fatalf("bootstrapWords len = %d, want 100", len(bootstrapWords))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapDefaultAdminPasswordUsesNestedConfigWhenSet(t *testing.T) {
|
||||
origNested := settings.Config.Auth.Methods.PasswordAuth.AdminPassword
|
||||
origLegacy := settings.Config.Auth.AdminPassword
|
||||
@@ -51,6 +58,8 @@ func TestBootstrapDefaultAdminPasswordUsesTopLevelWhenNestedUnset(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
var speakableBootstrapPasswordPattern = regexp.MustCompile(`^[a-z]+-[abcdefghjkmnpqrstuvwxyz23456789]{5}-[abcdefghjkmnpqrstuvwxyz23456789]{2}$`)
|
||||
|
||||
func TestBootstrapDefaultAdminPasswordGeneratesWhenDefault(t *testing.T) {
|
||||
origNested := settings.Config.Auth.Methods.PasswordAuth.AdminPassword
|
||||
origLegacy := settings.Config.Auth.AdminPassword
|
||||
@@ -72,11 +81,31 @@ func TestBootstrapDefaultAdminPasswordGeneratesWhenDefault(t *testing.T) {
|
||||
if plain == "" || plain == "admin" {
|
||||
t.Fatalf("cfg=%q: unexpected password %q", cfg, plain)
|
||||
}
|
||||
if len(plain) != bootstrapAdminPasswordHexBytes*2 {
|
||||
t.Fatalf("cfg=%q: password length %d, want %d", cfg, len(plain), bootstrapAdminPasswordHexBytes*2)
|
||||
if !speakableBootstrapPasswordPattern.MatchString(plain) {
|
||||
t.Fatalf("cfg=%q: password %q does not match speakable pattern", cfg, plain)
|
||||
}
|
||||
if strings.Trim(plain, "0123456789abcdef") != "" {
|
||||
t.Fatalf("cfg=%q: expected hex password, got %q", cfg, plain)
|
||||
word := strings.Split(plain, "-")[0]
|
||||
found := false
|
||||
for _, w := range bootstrapWords {
|
||||
if w == word {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("cfg=%q: word %q not in bootstrap word list", cfg, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSpeakableBootstrapPassword(t *testing.T) {
|
||||
for i := 0; i < 20; i++ {
|
||||
plain, err := generateSpeakableBootstrapPassword()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !speakableBootstrapPasswordPattern.MatchString(plain) {
|
||||
t.Fatalf("password %q does not match speakable pattern", plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package state
|
||||
|
||||
// bootstrapWords is a fixed list of short, common English nouns for one-time bootstrap passwords.
|
||||
// Words are chosen to be easy to say, spell, and type (including for non-native English speakers).
|
||||
var bootstrapWords = []string{
|
||||
"apple", "ball", "barn", "bean", "bed", "belt", "bike", "bird", "boat", "book",
|
||||
"boot", "bowl", "box", "bus", "cake", "camp", "cart", "cat", "clock", "cloud",
|
||||
"coat", "coin", "corn", "cup", "desk", "dog", "door", "duck", "egg", "farm",
|
||||
"fish", "flag", "food", "foot", "game", "gift", "goat", "gold", "grass", "hand",
|
||||
"hat", "hill", "home", "horse", "house", "ice", "jar", "key", "kid", "lake",
|
||||
"lamp", "land", "leaf", "leg", "light", "line", "lion", "list", "mail", "map",
|
||||
"milk", "moon", "mouse", "nest", "net", "night", "nose", "note", "page", "park",
|
||||
"path", "pen", "pet", "pie", "pig", "pin", "pipe", "plan", "plant", "pot",
|
||||
"rain", "red", "ring", "road", "rock", "room", "rope", "rose", "sand", "sea",
|
||||
"ship", "shoe", "shop", "sky", "snow", "sock", "star", "stone", "sun", "tree",
|
||||
}
|
||||
|
||||
// bootstrapSpeakableCharset excludes ambiguous characters for read-aloud (no 0/o, 1/i/l).
|
||||
const bootstrapSpeakableCharset = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
|
||||
// bootstrapSpeakablePrimaryCodeLen and bootstrapSpeakableSecondaryCodeLen form the code after the word (e.g. word-xxxxx-xx).
|
||||
const bootstrapSpeakablePrimaryCodeLen = 5
|
||||
const bootstrapSpeakableSecondaryCodeLen = 2
|
||||
@@ -52,10 +52,13 @@ func QuickSetup() error {
|
||||
|
||||
logger.Debugf("Creating user as admin: %v", user.Username)
|
||||
|
||||
plainPassword, _, err := bootstrapDefaultAdminPassword(user.Username)
|
||||
plainPassword, generated, err := bootstrapDefaultAdminPassword(user.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if generated {
|
||||
user.RequirePasswordChange = true
|
||||
}
|
||||
|
||||
hashedPassword, hashErr := utils.HashPwd(plainPassword)
|
||||
if hashErr != nil {
|
||||
|
||||
@@ -535,6 +535,12 @@ func ApplicableFolderQuotas(sourcePath, destIndexPath string, principalUserID ui
|
||||
return out
|
||||
}
|
||||
|
||||
type reserveApplyPlan struct {
|
||||
chk QuotaReserveCheck
|
||||
existingIdx int
|
||||
counterDelta int64
|
||||
}
|
||||
|
||||
// ReserveQuota increases reserved bytes for applicable quotas.
|
||||
func ReserveQuota(sessionID string, checks []QuotaReserveCheck) error {
|
||||
if sessionID == "" || len(checks) == 0 {
|
||||
@@ -543,19 +549,57 @@ func ReserveQuota(sessionID string, checks []QuotaReserveCheck) error {
|
||||
quotasMux.Lock()
|
||||
defer quotasMux.Unlock()
|
||||
|
||||
plans := make([]reserveApplyPlan, 0, len(checks))
|
||||
for _, chk := range checks {
|
||||
if chk.LimitBytes <= 0 || chk.DeltaBytes <= 0 {
|
||||
continue
|
||||
}
|
||||
reserved := sumSessionReservedLocked(chk.QuotaID)
|
||||
if chk.UsedBytes+reserved+chk.DeltaBytes > chk.LimitBytes {
|
||||
existingIdx := -1
|
||||
var existingBytes int64
|
||||
for i, r := range reservationsBySession[sessionID] {
|
||||
if r.QuotaID == chk.QuotaID {
|
||||
existingIdx = i
|
||||
existingBytes = r.Bytes
|
||||
break
|
||||
}
|
||||
}
|
||||
if existingIdx >= 0 && existingBytes == chk.DeltaBytes {
|
||||
continue
|
||||
}
|
||||
reservedOther := sumSessionReservedLocked(chk.QuotaID)
|
||||
if existingIdx >= 0 {
|
||||
reservedOther -= existingBytes
|
||||
}
|
||||
if chk.UsedBytes+reservedOther+chk.DeltaBytes > chk.LimitBytes {
|
||||
return fmt.Errorf("quota exceeded for %s", chk.QuotaID)
|
||||
}
|
||||
counterDelta := chk.DeltaBytes
|
||||
if existingIdx >= 0 {
|
||||
counterDelta = chk.DeltaBytes - existingBytes
|
||||
}
|
||||
plans = append(plans, reserveApplyPlan{
|
||||
chk: chk,
|
||||
existingIdx: existingIdx,
|
||||
counterDelta: counterDelta,
|
||||
})
|
||||
}
|
||||
|
||||
for _, plan := range plans {
|
||||
chk := plan.chk
|
||||
if chk.Meter == quota.MeterAccounted || chk.Kind == "share" {
|
||||
if err := ensureQuotaCounterMemLocked(chk.QuotaID); err != nil {
|
||||
return err
|
||||
}
|
||||
quotaCounters[chk.QuotaID].ReservedBytes += chk.DeltaBytes
|
||||
}
|
||||
}
|
||||
for _, plan := range plans {
|
||||
chk := plan.chk
|
||||
if chk.Meter == quota.MeterAccounted || chk.Kind == "share" {
|
||||
quotaCounters[chk.QuotaID].ReservedBytes += plan.counterDelta
|
||||
}
|
||||
if plan.existingIdx >= 0 {
|
||||
reservationsBySession[sessionID][plan.existingIdx].Bytes = chk.DeltaBytes
|
||||
continue
|
||||
}
|
||||
reservationsBySession[sessionID] = append(reservationsBySession[sessionID], quotaReservation{
|
||||
QuotaID: chk.QuotaID,
|
||||
@@ -671,6 +715,98 @@ func ForceCommitSessionQuota(sessionID string, deltaBytes int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAdjustments holds accounted quota updates to apply after a successful delete.
|
||||
type DeleteAdjustments struct {
|
||||
Deltas map[string]int64
|
||||
Resets []string
|
||||
}
|
||||
|
||||
// PrepareAccountedDelete captures quota adjustments while deleted paths are still indexed.
|
||||
// Folder quotas rooted inside a deleted directory are reset; covering quotas use itemBytes.
|
||||
func PrepareAccountedDelete(principal *users.User, sourceName, sourcePath, indexPath, shareHash string, isDir bool, itemBytes int64) DeleteAdjustments {
|
||||
adj := DeleteAdjustments{Deltas: make(map[string]int64)}
|
||||
if principal == nil {
|
||||
return adj
|
||||
}
|
||||
indexPath = normalizeQuotaPath(indexPath)
|
||||
for _, fq := range ListFolderQuotasForSource(sourcePath) {
|
||||
if fq.LimitBytes <= 0 || (fq.UserID != 0 && fq.UserID != principal.ID) {
|
||||
continue
|
||||
}
|
||||
if isDir && indexPathCovers(indexPath, fq.Path) {
|
||||
if quota.EffectiveMeter(configuredFolderMeter(fq), sourceName, fq.Path) != quota.MeterAccounted {
|
||||
continue
|
||||
}
|
||||
adj.Resets = append(adj.Resets, fq.ID)
|
||||
continue
|
||||
}
|
||||
if !indexPathCovers(fq.Path, indexPath) {
|
||||
continue
|
||||
}
|
||||
if quota.EffectiveMeter(configuredFolderMeter(fq), sourceName, fq.Path) != quota.MeterAccounted {
|
||||
continue
|
||||
}
|
||||
if itemBytes > 0 {
|
||||
adj.Deltas[fq.ID] = -itemBytes
|
||||
}
|
||||
}
|
||||
if itemBytes > 0 {
|
||||
for _, id := range accountedScopeQuotaIDs(principal, sourceName, sourcePath) {
|
||||
adj.Deltas[id] = -itemBytes
|
||||
}
|
||||
if shareHash != "" {
|
||||
adj.Deltas[quota.ShareQuotaID(shareHash)] = -itemBytes
|
||||
}
|
||||
}
|
||||
return adj
|
||||
}
|
||||
|
||||
// ApplyAccountedDelete applies prepared adjustments only after a successful deletion.
|
||||
func ApplyAccountedDelete(adj DeleteAdjustments) error {
|
||||
quotasMux.Lock()
|
||||
defer quotasMux.Unlock()
|
||||
defer signalQuotaFlush()
|
||||
for id, delta := range adj.Deltas {
|
||||
if err := applyCounterDeltaLocked(id, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range adj.Resets {
|
||||
if err := resetAccountedUsageLocked(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetAccountedUsageLocked(quotaID string) error {
|
||||
if err := ensureQuotaCounterMemLocked(quotaID); err != nil {
|
||||
return err
|
||||
}
|
||||
c := quotaCounters[quotaID]
|
||||
c.UsedBytes = 0
|
||||
c.Dirty = true
|
||||
markQuotaCounterDirty(quotaID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func accountedScopeQuotaIDs(principal *users.User, sourceName, sourcePath string) []string {
|
||||
var ids []string
|
||||
for _, bs := range principal.BackendScopes {
|
||||
if bs.Path != sourcePath || bs.Quota == nil || bs.Quota.LimitBytes <= 0 {
|
||||
continue
|
||||
}
|
||||
configured := bs.Quota.Meter
|
||||
if configured == "" {
|
||||
configured = quota.MeterIndexScope
|
||||
}
|
||||
if quota.EffectiveMeter(configured, sourceName, scopeIndexPath(principal, sourceName)) == quota.MeterAccounted {
|
||||
ids = append(ids, bs.Quota.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ApplyAccountedUsageDelta adjusts accounted and share quota counters for a path (negative delta frees space).
|
||||
func ApplyAccountedUsageDelta(principal *users.User, sourceName, sourcePath, indexPath, shareHash string, delta int64) error {
|
||||
if delta == 0 || principal == nil {
|
||||
@@ -691,21 +827,8 @@ func ApplyAccountedUsageDelta(principal *users.User, sourceName, sourcePath, ind
|
||||
}
|
||||
}
|
||||
|
||||
for _, bs := range principal.BackendScopes {
|
||||
if bs.Path != sourcePath || bs.Quota == nil || bs.Quota.LimitBytes <= 0 {
|
||||
continue
|
||||
}
|
||||
sq := bs.Quota
|
||||
configured := sq.Meter
|
||||
if configured == "" {
|
||||
configured = quota.MeterIndexScope
|
||||
}
|
||||
scopePath := scopeIndexPath(principal, sourceName)
|
||||
effective := quota.EffectiveMeter(configured, sourceName, scopePath)
|
||||
if effective != quota.MeterAccounted {
|
||||
continue
|
||||
}
|
||||
if err := applyCounterDeltaLocked(sq.ID, delta); err != nil {
|
||||
for _, id := range accountedScopeQuotaIDs(principal, sourceName, sourcePath) {
|
||||
if err := applyCounterDeltaLocked(id, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
)
|
||||
|
||||
func TestPrepareAccountedDelete(t *testing.T) {
|
||||
oldFolders, oldSources, oldCounters := folderQuotasByID, folderQuotasBySource, quotaCounters
|
||||
folderQuotasByID = make(map[string]*quota.FolderQuota)
|
||||
folderQuotasBySource = make(map[string][]string)
|
||||
quotaCounters = make(map[string]*quotaCounterMem)
|
||||
t.Cleanup(func() {
|
||||
folderQuotasByID, folderQuotasBySource, quotaCounters = oldFolders, oldSources, oldCounters
|
||||
indexing.ClearTestIndices()
|
||||
})
|
||||
indexing.SetTestIndex("delete", "/source")
|
||||
idx := indexing.GetIndex("delete")
|
||||
idx.SetFolderSize("/public/", 60)
|
||||
idx.SetFolderSize("/public/nested/", 50)
|
||||
idx.SetFolderSize("/public/nested/deeper/", 30)
|
||||
principal := &users.User{ID: 1, BackendScopes: []users.BackendScope{{Path: "/source", Scope: "/", Quota: &users.ScopeQuota{ID: "scope", LimitBytes: 1000, Meter: quota.MeterAccounted}}}}
|
||||
for _, q := range []quota.FolderQuota{
|
||||
{ID: "cover", Source: "/source", Path: "/", Meter: quota.MeterAccounted},
|
||||
{ID: "exact", Source: "/source", Path: "/public", Meter: quota.MeterAccounted},
|
||||
{ID: "nested", Source: "/source", Path: "/public/nested", Meter: quota.MeterAccounted, UserID: 1},
|
||||
{ID: "deeper", Source: "/source", Path: "/public/nested/deeper", Meter: quota.MeterAccounted},
|
||||
{ID: "missing", Source: "/source", Path: "/public/missing", Meter: quota.MeterAccounted},
|
||||
{ID: "sibling", Source: "/source", Path: "/sibling", Meter: quota.MeterAccounted},
|
||||
{ID: "prefix", Source: "/source", Path: "/public-other", Meter: quota.MeterAccounted},
|
||||
{ID: "other-user", Source: "/source", Path: "/public/nested", Meter: quota.MeterAccounted, UserID: 2},
|
||||
{ID: "other-source", Source: "/other", Path: "/public/nested", Meter: quota.MeterAccounted},
|
||||
{ID: "index", Source: "/source", Path: "/public/nested", Meter: quota.MeterIndexSize},
|
||||
} {
|
||||
q.LimitBytes = 1000
|
||||
storeFolderQuota(q)
|
||||
quotaCounters[q.ID] = "aCounterMem{UsedBytes: 100, ReservedBytes: 7}
|
||||
}
|
||||
shareID := quota.ShareQuotaID("share")
|
||||
quotaCounters["scope"] = "aCounterMem{UsedBytes: 100, ReservedBytes: 7}
|
||||
quotaCounters[shareID] = "aCounterMem{UsedBytes: 100, ReservedBytes: 7}
|
||||
wantDeltas := map[string]int64{"cover": -60, "scope": -60, shareID: -60}
|
||||
wantResets := []string{"exact", "nested", "deeper", "missing"}
|
||||
got := PrepareAccountedDelete(principal, "delete", "/source", "/public", "share", true, 60)
|
||||
if !reflect.DeepEqual(got.Deltas, wantDeltas) {
|
||||
t.Fatalf("deltas = %v, want %v", got.Deltas, wantDeltas)
|
||||
}
|
||||
if !slicesEqual(got.Resets, wantResets) {
|
||||
t.Fatalf("resets = %v, want %v", got.Resets, wantResets)
|
||||
}
|
||||
for id, delta := range wantDeltas {
|
||||
quotaCounters[id].UsedBytes = -delta
|
||||
}
|
||||
quotaCounters["cover"].UsedBytes += 40
|
||||
for _, id := range wantResets {
|
||||
quotaCounters[id].UsedBytes = 100
|
||||
}
|
||||
if err := ApplyAccountedDelete(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for id, c := range quotaCounters {
|
||||
expected := int64(100)
|
||||
if _, ok := wantDeltas[id]; ok {
|
||||
expected = 0
|
||||
}
|
||||
if slices.Contains(wantResets, id) {
|
||||
expected = 0
|
||||
}
|
||||
if id == "cover" {
|
||||
expected = 40
|
||||
}
|
||||
if c.UsedBytes != expected || c.ReservedBytes != 7 {
|
||||
t.Errorf("%s: used=%d reserved=%d, want %d/7", id, c.UsedBytes, c.ReservedBytes, expected)
|
||||
}
|
||||
}
|
||||
for _, size := range []int64{0, -1} {
|
||||
got = PrepareAccountedDelete(principal, "delete", "/source", "/public", "", true, size)
|
||||
wantResetsUnknown := []string{"exact", "nested", "deeper", "missing"}
|
||||
if len(got.Deltas) != 0 || !slicesEqual(got.Resets, wantResetsUnknown) {
|
||||
t.Fatalf("unavailable/zero ancestor: deltas=%v resets=%v", got.Deltas, got.Resets)
|
||||
}
|
||||
}
|
||||
got = PrepareAccountedDelete(principal, "delete", "/source", "/public/nested/file", "", false, 5)
|
||||
if !reflect.DeepEqual(got, DeleteAdjustments{
|
||||
Deltas: map[string]int64{"cover": -5, "exact": -5, "nested": -5, "scope": -5},
|
||||
}) {
|
||||
t.Fatalf("file adjustments: %v", got)
|
||||
}
|
||||
empty := PrepareAccountedDelete(nil, "delete", "/source", "/public", "share", true, 60)
|
||||
if len(empty.Deltas) != 0 || len(empty.Resets) != 0 {
|
||||
t.Fatalf("nil principal: %v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareAccountedDeleteResetsWithoutIndexedSizes(t *testing.T) {
|
||||
oldFolders, oldSources, oldCounters := folderQuotasByID, folderQuotasBySource, quotaCounters
|
||||
folderQuotasByID = make(map[string]*quota.FolderQuota)
|
||||
folderQuotasBySource = make(map[string][]string)
|
||||
quotaCounters = make(map[string]*quotaCounterMem)
|
||||
t.Cleanup(func() {
|
||||
folderQuotasByID, folderQuotasBySource, quotaCounters = oldFolders, oldSources, oldCounters
|
||||
indexing.ClearTestIndices()
|
||||
})
|
||||
indexing.SetTestIndex("delete", "/source")
|
||||
principal := &users.User{ID: 1}
|
||||
nested := quota.FolderQuota{ID: "nested", Source: "/source", Path: "/public/nested", Meter: quota.MeterAccounted, LimitBytes: 1000}
|
||||
storeFolderQuota(nested)
|
||||
quotaCounters["nested"] = "aCounterMem{UsedBytes: 50, ReservedBytes: 3}
|
||||
got := PrepareAccountedDelete(principal, "delete", "/source", "/public", "", true, 0)
|
||||
if !slicesEqual(got.Resets, []string{"nested"}) {
|
||||
t.Fatalf("resets = %v, want nested reset without index sizes", got.Resets)
|
||||
}
|
||||
if err := ApplyAccountedDelete(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if used, reserved := GetQuotaCounterSnapshot("nested"); used != 0 || reserved != 3 {
|
||||
t.Fatalf("nested used=%d reserved=%d, want 0/3", used, reserved)
|
||||
}
|
||||
}
|
||||
|
||||
func slicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
ac := slices.Clone(a)
|
||||
bc := slices.Clone(b)
|
||||
slices.Sort(ac)
|
||||
slices.Sort(bc)
|
||||
return slices.Equal(ac, bc)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
|
||||
)
|
||||
|
||||
func withCleanQuotaReservations(t *testing.T) {
|
||||
oldReservations := reservationsBySession
|
||||
oldCounters := quotaCounters
|
||||
initQuotaMaps()
|
||||
t.Cleanup(func() {
|
||||
quotasMux.Lock()
|
||||
reservationsBySession = oldReservations
|
||||
quotaCounters = oldCounters
|
||||
quotasMux.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestReserveQuota_idempotentReplaceUsesNewTotalNotIncrementTwice(t *testing.T) {
|
||||
withCleanQuotaReservations(t)
|
||||
|
||||
const quotaID = "folder-q1"
|
||||
check := func(delta int64) QuotaReserveCheck {
|
||||
return QuotaReserveCheck{
|
||||
QuotaID: quotaID,
|
||||
LimitBytes: 100,
|
||||
UsedBytes: 0,
|
||||
DeltaBytes: delta,
|
||||
}
|
||||
}
|
||||
|
||||
if err := ReserveQuota("upload-session", []QuotaReserveCheck{check(40)}); err != nil {
|
||||
t.Fatalf("initial reserve: %v", err)
|
||||
}
|
||||
if got := SessionReservedForQuota(quotaID); got != 40 {
|
||||
t.Fatalf("after 40: reserved=%d", got)
|
||||
}
|
||||
|
||||
if err := ReserveQuota("upload-session", []QuotaReserveCheck{check(70)}); err != nil {
|
||||
t.Fatalf("replace with 70: %v", err)
|
||||
}
|
||||
if got := SessionReservedForQuota(quotaID); got != 70 {
|
||||
t.Fatalf("after 70: reserved=%d want 70", got)
|
||||
}
|
||||
|
||||
if err := ReserveQuota("upload-session", []QuotaReserveCheck{check(101)}); err == nil {
|
||||
t.Fatal("expected quota exceeded when replacing with 101")
|
||||
}
|
||||
if got := SessionReservedForQuota(quotaID); got != 70 {
|
||||
t.Fatalf("after failed 101: reserved=%d want 70 unchanged", got)
|
||||
}
|
||||
|
||||
if err := ReserveQuota("upload-session", []QuotaReserveCheck{check(100)}); err != nil {
|
||||
t.Fatalf("replace with 100 at limit: %v", err)
|
||||
}
|
||||
if got := SessionReservedForQuota(quotaID); got != 100 {
|
||||
t.Fatalf("after 100: reserved=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveQuota_multiCheckAtomicOnFailure(t *testing.T) {
|
||||
withCleanQuotaReservations(t)
|
||||
|
||||
ok := QuotaReserveCheck{QuotaID: "q-ok", LimitBytes: 1000, UsedBytes: 0, DeltaBytes: 200}
|
||||
if err := ReserveQuota("sess", []QuotaReserveCheck{ok}); err != nil {
|
||||
t.Fatalf("setup reserve: %v", err)
|
||||
}
|
||||
if got := SessionReservedForQuota("q-ok"); got != 200 {
|
||||
t.Fatalf("setup reserved=%d", got)
|
||||
}
|
||||
|
||||
tight := QuotaReserveCheck{QuotaID: "q-tight", LimitBytes: 50, UsedBytes: 0, DeltaBytes: 60}
|
||||
batch := []QuotaReserveCheck{
|
||||
{QuotaID: "q-ok", LimitBytes: 1000, UsedBytes: 0, DeltaBytes: 500},
|
||||
tight,
|
||||
}
|
||||
if err := ReserveQuota("sess", batch); err == nil {
|
||||
t.Fatal("expected second quota in batch to fail")
|
||||
}
|
||||
if got := SessionReservedForQuota("q-ok"); got != 200 {
|
||||
t.Fatalf("first quota must be unchanged after failed batch, reserved=%d", got)
|
||||
}
|
||||
if got := SessionReservedForQuota("q-tight"); got != 0 {
|
||||
t.Fatalf("failed quota must not be reserved, got=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Accounted meters run counter ensure before any reservation mutation; a failed batch must
|
||||
// leave session reservations unchanged (two-pass ReserveQuota).
|
||||
func TestReserveQuota_accountedBatchAtomicOnFailure(t *testing.T) {
|
||||
withCleanQuotaReservations(t)
|
||||
if sqlDb == nil {
|
||||
t.Skip("requires initialized SQL store for accounted quota counters")
|
||||
}
|
||||
|
||||
first := QuotaReserveCheck{
|
||||
QuotaID: "q-accounted-a",
|
||||
Kind: "share",
|
||||
Meter: quota.MeterAccounted,
|
||||
LimitBytes: 1000,
|
||||
UsedBytes: 0,
|
||||
DeltaBytes: 100,
|
||||
}
|
||||
if err := ReserveQuota("sess-acct", []QuotaReserveCheck{first}); err != nil {
|
||||
t.Fatalf("setup reserve: %v", err)
|
||||
}
|
||||
if got := SessionReservedForQuota("q-accounted-a"); got != 100 {
|
||||
t.Fatalf("setup reserved=%d", got)
|
||||
}
|
||||
|
||||
batch := []QuotaReserveCheck{
|
||||
{QuotaID: "q-accounted-a", Kind: "share", Meter: quota.MeterAccounted, LimitBytes: 1000, UsedBytes: 0, DeltaBytes: 500},
|
||||
{QuotaID: "q-accounted-b", Kind: "share", Meter: quota.MeterAccounted, LimitBytes: 50, UsedBytes: 0, DeltaBytes: 60},
|
||||
}
|
||||
if err := ReserveQuota("sess-acct", batch); err == nil {
|
||||
t.Fatal("expected second accounted quota in batch to fail")
|
||||
}
|
||||
if got := SessionReservedForQuota("q-accounted-a"); got != 100 {
|
||||
t.Fatalf("first accounted reservation must be unchanged after failed batch, reserved=%d", got)
|
||||
}
|
||||
if got := SessionReservedForQuota("q-accounted-b"); got != 0 {
|
||||
t.Fatalf("failed accounted quota must not be reserved, got=%d", got)
|
||||
}
|
||||
}
|
||||
@@ -348,14 +348,19 @@ func UpdateSharesForMovedResource(oldSource, oldPath, newSource, newPath string)
|
||||
continue
|
||||
}
|
||||
norm := utils.AddTrailingSlashIfNotExists(l.Path)
|
||||
if !strings.Contains(norm, oldPath) {
|
||||
if !strings.HasPrefix(norm, oldPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
removeShareFromPathIndexLocked(l.SourcePath, l.Path, l.Hash)
|
||||
|
||||
shareWasDir := strings.HasSuffix(l.Path, "/")
|
||||
l.SourcePath = newSource
|
||||
l.Path = newPath
|
||||
suffix := strings.TrimPrefix(norm, oldPath)
|
||||
l.Path = strings.TrimSuffix(newPath+suffix, "/")
|
||||
if shareWasDir {
|
||||
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
|
||||
}
|
||||
|
||||
if err := sqlDb.SaveShare(l); err != nil {
|
||||
logger.Error("failed to save updated share after resource move", "hash", l.Hash, "error", err)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
)
|
||||
|
||||
func rewriteSharePathAfterMove(sharePath, oldPath, newPath string) string {
|
||||
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
|
||||
newPath = utils.AddTrailingSlashIfNotExists(newPath)
|
||||
norm := utils.AddTrailingSlashIfNotExists(sharePath)
|
||||
if !strings.HasPrefix(norm, oldPath) {
|
||||
return sharePath
|
||||
}
|
||||
suffix := strings.TrimPrefix(norm, oldPath)
|
||||
shareWasDir := strings.HasSuffix(sharePath, "/")
|
||||
updated := strings.TrimSuffix(newPath+suffix, "/")
|
||||
if shareWasDir {
|
||||
updated = utils.AddTrailingSlashIfNotExists(updated)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func TestRewriteSharePathAfterMove(t *testing.T) {
|
||||
got := rewriteSharePathAfterMove("/photos/2024/one.jpg", "/photos/", "/archive/photos/")
|
||||
want := "/archive/photos/2024/one.jpg"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
if rewriteSharePathAfterMove("/backup/photos/", "/photos/", "/archive/photos/") != "/backup/photos/" {
|
||||
t.Fatal("substring match must not retarget unrelated share")
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,16 @@ func initialize(dbPath string) (bool, error) {
|
||||
|
||||
newAccessDb.SetSQLStore(sqlDb)
|
||||
|
||||
accessRulesLowercased, err := sqlDb.AccessRulesLowercaseMigrated()
|
||||
if err != nil {
|
||||
return existingDb, fmt.Errorf("failed to read access rules migration marker: %w", err)
|
||||
}
|
||||
if !accessRulesLowercased {
|
||||
if migErr := newAccessDb.StartupMigrateAccessRuleKeys(); migErr != nil {
|
||||
return existingDb, fmt.Errorf("failed to migrate access rule paths: %w", migErr)
|
||||
}
|
||||
}
|
||||
|
||||
usersMux.Lock()
|
||||
sharesMux.Lock()
|
||||
indexMux.Lock()
|
||||
|
||||
@@ -273,6 +273,23 @@ func EffectiveUserDefaults() settings.UserDefaults {
|
||||
return userDefaultsDefault
|
||||
}
|
||||
|
||||
// SetEffectiveUserDefaultsForTest replaces in-memory universal defaults (tests only).
|
||||
func SetEffectiveUserDefaultsForTest(t interface {
|
||||
Helper()
|
||||
Cleanup(func())
|
||||
}, d settings.UserDefaults) {
|
||||
t.Helper()
|
||||
userDefaultsMu.Lock()
|
||||
previous := userDefaultsDefault
|
||||
userDefaultsDefault = d
|
||||
userDefaultsMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
userDefaultsMu.Lock()
|
||||
userDefaultsDefault = previous
|
||||
userDefaultsMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyUserDefaults applies persisted defaults to a user, including sidebar link and tool access defaults.
|
||||
func ApplyUserDefaults(u *users.User) {
|
||||
applyUserSettingsDefaults(u)
|
||||
|
||||
@@ -6,18 +6,38 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/toolaccess"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// User operations
|
||||
|
||||
// backendScopeKey identifies a scope by its full identity for permission restore: scopes
|
||||
// may share a source path while carrying different permissions per sub-scope.
|
||||
type backendScopeKey struct {
|
||||
path string
|
||||
scope string
|
||||
}
|
||||
|
||||
// backendScopeKeyFor normalizes an empty scope the same way MergeDefaultEnabledBackendScopes
|
||||
// does (source defaultUserScope) so snapshot keys still match after defaults are applied.
|
||||
func backendScopeKeyFor(scope users.BackendScope) backendScopeKey {
|
||||
s := scope.Scope
|
||||
if s == "" {
|
||||
if src, ok := settings.Config.Server.SourceMap[scope.Path]; ok && src != nil {
|
||||
s = src.Config.DefaultUserScope
|
||||
}
|
||||
}
|
||||
return backendScopeKey{path: scope.Path, scope: s}
|
||||
}
|
||||
|
||||
// copyBackendSourcePermissions returns a shallow copy of the per-source permissions map.
|
||||
func copyBackendSourcePermissions(m map[string]users.SourceFilePermissions) map[string]users.SourceFilePermissions {
|
||||
if m == nil {
|
||||
@@ -192,6 +212,9 @@ func CreateUser(user *users.User, plaintextPassword string) error {
|
||||
}
|
||||
// Hash password if provided
|
||||
if plaintextPassword != "" {
|
||||
if err := settings.ValidatePasswordPolicy(plaintextPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
hashedPassword, err := utils.HashPwd(plaintextPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
@@ -199,19 +222,68 @@ func CreateUser(user *users.User, plaintextPassword string) error {
|
||||
user.Password = hashedPassword
|
||||
}
|
||||
|
||||
// Merge global permissions before converting API scopes: the final Admin value
|
||||
// (including account defaults when the request omits admin) decides which source
|
||||
// permissions applyScopesFromAPI assigns to scopes that omit permissions.
|
||||
preserveRequirePasswordChange := user.RequirePasswordChange
|
||||
requestedPermissions := user.Permissions
|
||||
globalMask := user.EffectiveGlobalPermissionsMask()
|
||||
// Configured distinguishes an explicit all-false permissions object from omission
|
||||
// (zero value). Any set bit still counts so Go callers that set Admin without
|
||||
// MarkPermissionsConfigured (e.g. OIDC admin promotion) keep working.
|
||||
permissionsRequested := globalMask.Any() || requestedPermissions.Configured ||
|
||||
requestedPermissions.Admin || requestedPermissions.Share ||
|
||||
requestedPermissions.Api || requestedPermissions.Realtime ||
|
||||
requestedPermissions.HasAnyFilePermission()
|
||||
if globalMask.Any() || !permissionsRequested {
|
||||
defaultPerms := settings.ConvertPermissionsToUsers(EffectiveUserDefaults().Account.Permissions)
|
||||
user.Permissions = users.MergeGlobalPermissionsOnCreate(defaultPerms, requestedPermissions, globalMask)
|
||||
} else {
|
||||
user.Permissions = requestedPermissions
|
||||
}
|
||||
|
||||
// Incoming API "scopes" → BackendScopes; FrontendScopes must not remain on the persisted user.
|
||||
if err := applyScopesFromAPI(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If still no BackendScopes (omitted or invalid API names), same defaults as ApplyUserDefaults.
|
||||
applyUserSettingsDefaults(user)
|
||||
// Snapshot caller-supplied scope permissions by full scope identity so the defaults
|
||||
// application below cannot collapse scopes sharing a source path.
|
||||
requestedScopePermissions := make(map[backendScopeKey]users.SourceFilePermissions, len(user.BackendScopes))
|
||||
for _, scope := range user.BackendScopes {
|
||||
if !scope.Permissions.IsUnset() {
|
||||
requestedScopePermissions[backendScopeKeyFor(scope)] = scope.Permissions
|
||||
}
|
||||
}
|
||||
// Apply profile/scope defaults using the final account permissions (admin flag drives scope defaults).
|
||||
if permissionsRequested {
|
||||
settings.ApplyUserDefaultsFromKeepingAccountPermissions(user, EffectiveUserDefaults())
|
||||
} else {
|
||||
applyUserSettingsDefaults(user)
|
||||
}
|
||||
if preserveRequirePasswordChange {
|
||||
user.RequirePasswordChange = true
|
||||
}
|
||||
user.PermissionsFieldMask = users.GlobalPermissionsFieldMask{}
|
||||
for i, scope := range user.BackendScopes {
|
||||
if perms, ok := requestedScopePermissions[backendScopeKeyFor(scope)]; ok {
|
||||
user.BackendScopes[i].Permissions = perms
|
||||
if user.BackendSourcePermissions == nil {
|
||||
user.BackendSourcePermissions = make(map[string]users.SourceFilePermissions)
|
||||
}
|
||||
user.BackendSourcePermissions[scope.Path] = perms
|
||||
}
|
||||
}
|
||||
defaults := EffectiveUserDefaults()
|
||||
enforced := EffectiveEnforced()
|
||||
settings.ApplyEnforcedDefaultsFrom(user, defaults, enforced)
|
||||
settings.ApplyEnforcedSourcePermissionsFrom(user, GetSourceAccessDefaults(), GetEnforcedSourcePermissions())
|
||||
|
||||
users.SyncBackendSourcePermissionsMap(user)
|
||||
if !user.Permissions.Admin {
|
||||
settings.ExpandBackendScopesForCreateUserDir(user)
|
||||
}
|
||||
|
||||
applySidebarLinkDefaults(user)
|
||||
|
||||
@@ -296,6 +368,9 @@ func UpdateUser(user *users.User, plaintextPassword string, fields ...string) er
|
||||
if plaintextPassword == "" {
|
||||
return fmt.Errorf("password field requires a non-empty plaintext password")
|
||||
}
|
||||
if err := settings.ValidatePasswordPolicy(plaintextPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
hashedPassword, hashErr := utils.HashPwd(plaintextPassword)
|
||||
if hashErr != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", hashErr)
|
||||
@@ -408,6 +483,11 @@ func commitUserUpdate(existingUser, storedSnapshot *users.User, sourceDefaults u
|
||||
if err := sqlDb.UpdateUserUsername(oldUsername, existingUser); err != nil {
|
||||
return err
|
||||
}
|
||||
if accessDb != nil {
|
||||
if err := renameUserInAccess(oldUsername, existingUser); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if oldUserID != 0 && oldUserID != existingUser.ID {
|
||||
userRecordCache.Delete(userCacheKeyID(oldUserID))
|
||||
}
|
||||
@@ -431,6 +511,31 @@ func commitUserUpdate(existingUser, storedSnapshot *users.User, sourceDefaults u
|
||||
return nil
|
||||
}
|
||||
|
||||
// renameUserInAccess renames the user in group memberships and path rules after the SQL rename
|
||||
// has been written. On failure every earlier step is reverted (keeping the other updated fields)
|
||||
// so the stores never disagree about which username owns the memberships.
|
||||
func renameUserInAccess(oldUsername string, updated *users.User) error {
|
||||
revertSQL := func() {
|
||||
reverted := *updated
|
||||
reverted.Username = oldUsername
|
||||
if err := sqlDb.UpdateUser(&reverted); err != nil {
|
||||
logger.Errorf("failed to revert username %q to %q after rename failure: %v", updated.Username, oldUsername, err)
|
||||
}
|
||||
}
|
||||
if err := accessDb.RenameUserInGroups(oldUsername, updated.Username); err != nil {
|
||||
revertSQL()
|
||||
return fmt.Errorf("failed to rename user in groups: %w", err)
|
||||
}
|
||||
if err := accessDb.RenameUserInRules(oldUsername, updated.Username); err != nil {
|
||||
if revErr := accessDb.RenameUserInGroups(updated.Username, oldUsername); revErr != nil {
|
||||
logger.Errorf("failed to revert group memberships for %q after rename failure: %v", oldUsername, revErr)
|
||||
}
|
||||
revertSQL()
|
||||
return fmt.Errorf("failed to rename user in access rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removedScopeQuotaIDs(before, after *users.User) []string {
|
||||
oldByPath := map[string]string{}
|
||||
for _, bs := range before.BackendScopes {
|
||||
@@ -577,6 +682,9 @@ func DeleteUser(id uint64) error {
|
||||
return fmt.Errorf("user not found in state")
|
||||
}
|
||||
|
||||
if err := removeUserAccess(user.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sqlDb.DeleteUserByID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -601,6 +709,9 @@ func DeleteUserByUsername(username string) error {
|
||||
}
|
||||
|
||||
uid := user.ID
|
||||
if err := removeUserAccess(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sqlDb.DeleteUserByUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -610,7 +721,23 @@ func DeleteUserByUsername(username string) error {
|
||||
if accessDb != nil && uid != 0 {
|
||||
_ = accessDb.RemoveHashedTokensForUser(uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeUserAccess strips a username from group memberships and path rules before the user row is
|
||||
// deleted. Each step rolls itself back on failure and any error aborts the delete, so a reused
|
||||
// username can never inherit leftovers. The failure mode is fail-safe: if the later SQL delete
|
||||
// fails the user only loses access, and a retry is idempotent.
|
||||
func removeUserAccess(username string) error {
|
||||
if accessDb == nil {
|
||||
return nil
|
||||
}
|
||||
if err := accessDb.RemoveUserFromAllGroups(username); err != nil {
|
||||
return fmt.Errorf("failed to remove user from groups: %w", err)
|
||||
}
|
||||
if err := accessDb.RemoveAllRulesForUser(username); err != nil {
|
||||
return fmt.Errorf("failed to remove user from access rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 571 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user