mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
117
Commits
v2.0.10-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 | ||
|
+11 |
45a14bf2f2 |
No files matched your search
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
|
||||
[*.go]
|
||||
indent_size = 4
|
||||
|
||||
[{Makefile,makefile}]
|
||||
indent_style = tab
|
||||
+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"
|
||||
@@ -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,6 +40,7 @@ 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
|
||||
@@ -46,6 +50,7 @@ jobs:
|
||||
run: npm i && npm run lint
|
||||
test-frontend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -55,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
|
||||
@@ -64,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
|
||||
@@ -73,6 +80,7 @@ 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
|
||||
|
||||
+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
|
||||
|
||||
+213
-9
@@ -2,6 +2,208 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v2.1.4
|
||||
|
||||
**Security**:
|
||||
- [Low] `GET /api/settings` and `GET /api/settings?property=auth` no longer return cleartext secrets (`auth.key`, `totpSecret`, `adminPassword`, OIDC `clientSecret`, LDAP bind password, recaptcha secret, OnlyOffice secret); secret fields are replaced with `**hidden**` in the response, and the YAML export now redacts the recaptcha and LDAP secrets whose comments lacked the `secret:` marker (GHSA-892f-9h5p-mfc8) reporter @paulmossman
|
||||
|
||||
**New Features**:
|
||||
- feat(search): sidebar File actions and close button in advanced search (#3303) (#2479) (#3303)
|
||||
|
||||
**Bug Fixes**:
|
||||
- Deleting a directory now releases folder quotas rooted inside it instead of leaving them charged for files that no longer exist; applies to API and WebDAV deletes (#3163) (#3235)
|
||||
- Bulk and public share deletes now release accounted quota usage; nested folder quotas reset when an ancestor is removed (#3163) (#3305)
|
||||
- Directory listings skip entries that return permission denied or not-found on per-entry stat instead of failing the whole folder (e.g. unreadable NTFS junctions on CIFS); skipped entries are omitted from the index like `ls -l` (#3184)
|
||||
- Preview generation for webp broken (#3254) (#3259)
|
||||
- Share banner never shows to public visitors, returns bannerUrl but not banner (#3246) (#3250)
|
||||
- fix html/markdown relative file preview support (#3131) (#3129) (#2355)
|
||||
- fix(archive): extract tar hard links instead of dropping them (#3234)
|
||||
- fix(auth): apply stored permission caps for customized API tokens with no global flags enabled (#3255)
|
||||
- Archive extraction writes regular and hard-link entries at the entry path instead of following an existing symlink at that path (tar and zip) (#3265)
|
||||
- fix(move-copy): require choosing a destination source in advanced search when none can be inferred, instead of silently copying to the source root (#3276)
|
||||
- fix(stream): single-file shares can now stream their own media on Windows by using slash-based path handling (#3270) (#3271)
|
||||
- fix(users): `lockPassword` is now enforced on self-service password changes; non-admins get 403 while admin resets still work (#3279) (#3283)
|
||||
- fix(analytics): the FreeBSD release binary now reports `freebsd` in `system.os` instead of `other` (#3244) (#3284)
|
||||
- fix(frontend): share expiration now reads backend Unix-seconds timestamps correctly instead of showing dates decades off (#3247)
|
||||
- fix(frontend): video on iPhone now uses the native fullscreen player instead of the in-page fallback (#3113) (#3288)
|
||||
- fix(frontend): the video loading spinner no longer stays on for the rest of playback when the fetch stalls but the buffer can continue (#3287) (#3289)
|
||||
- fix(frontend): the share banner is now shown to public visitors, using `bannerUrl` instead of the never-set `banner` field (#3246) (#3250)
|
||||
- fix(preview): camera RAW files (Panasonic `.rw2`/`.raw`/`.rwl`, Olympus `.orf`, `.cr2`/`.dng`) extract embedded previews from TIFF strips/SubIFDs and no longer return 415 when EXIF decoding fails (#3219) (#3243)
|
||||
- fix(indexing): restore lazy listing persistence with atomic folder-size deltas and ancestor propagation, and force a full scan for scanners that missed their slot while the process was down (#3260)
|
||||
- fix(indexing): directory listings skip entries that cannot be accessed or have vanished instead of failing the whole listing; remaining errors still fail and fully-vanished nonempty directories return an error (#3292)
|
||||
- fix(frontend): redirects from `/files` stay inside the files area so a source named "Files" no longer leaves an empty view after login (#3167) (#3304)
|
||||
|
||||
## v2.1.3
|
||||
|
||||
**Security**:
|
||||
- [Moderate] Passkey registration and deletion now require the account password when a passkey is already configured, and passkeys cannot be altered through generic user profile PATCH (GHSA-r744-4vm3-2px3) reporter @tonghuaroot
|
||||
- [Medium] Storage quota accounting now records upload byte deltas using the pre-replace file size, and upload paths without size metadata or with resumed chunk offsets cannot skip quota checks (GHSA-m57f-66qj-8jxg) reporter @jayhemnani9910
|
||||
- [High] Sources marked `readOnly` are now enforced on WebDAV writes, server-side archive/unarchive, and OnlyOffice save callbacks (GHSA-4v62-884x-2466) reporter @anpryl
|
||||
- [Moderate] Moving or renaming a folder updates only shares whose paths are under the moved prefix, preserving per-file share roots instead of retargeting unrelated substring matches (GHSA-26fh-r34c-qxh6) reporters @jayhemnani9910 @dylanzonix
|
||||
- [Moderate] WebDAV PUT now requires Create permission when the target path does not yet exist, so Modify-only users cannot create new files (GHSA-8655-2ph3-q7q7) reporter @levanduy181
|
||||
- [Low] Failed-login lockout and auth rate limits now use the rightmost `X-Forwarded-For` hop when proxy headers are trusted, so a client cannot spoof the client IP with a leftmost forwarded value (GHSA-wjp8-g49g-xrhx) reporter @LECOQQ
|
||||
- [Low] Duplicate finder results are cached per requesting user so filtered file names from one account are not served to another (GHSA-4fhx-8pf6-5gmr) reporter @Nazarami
|
||||
- [Moderate] Authenticated `GET /api/resources/view` no longer accepts view tokens minted for a public share when the caller lacks view permission on the requested source (GHSA-wxj5-g3qp-g732) reporter @BlueN0r
|
||||
- [Low] SSE source-update and user-targeted delivery hold the client registry lock through nonblocking sends, preventing concurrent map mutation and send-on-closed-channel panics when Realtime clients connect, disconnect, or shut down (GHSA-p9wm-f5jj-75pj) reporter @tao0845
|
||||
- [High] Incomplete symlink-containment remediation: an authenticated non-admin user could create a relative symlink via archive extraction, relocate it with WebDAV MOVE, and write through it to arbitrary host paths with the service account's privileges (GHSA-3j46-5w28-f8p2) reporter @Saku0512
|
||||
- [Moderate] Source-root containment bypass: create, write, delete, and move operations now resolve symlinks uncached and enforce the caller's scope, closing escapes through pre-existing out-of-source symlinks on upload, PUT, PATCH, delete, and WebDAV endpoints (GHSA-j764-qg3m-rhv7) reporter @chaoming0625
|
||||
- [Moderate] Preview reads resolve symlinks within the caller's scope so authenticated preview cannot follow an out-of-scope symlink target (GHSA-g2q5-r6ch-v25f) reporter @tao0845
|
||||
- [Moderate] Archive downloads and extraction no longer read or write through symlinks that resolve outside the caller's share or user scope; escaping archive-walk entries are skipped (GHSA-qqf3-2wvq-q4w9) reporter @SmokinCaterpillar
|
||||
- [Moderate] Share directory-archive downloads now apply the share owner's access rules to every entry, closing a bypass where anonymous share visitors could download admin-denied content (GHSA-9fcr-gm58-p839) reporter @Tan-JunWei
|
||||
- [Moderate] Access rule paths are stored and matched case-insensitively so rules cannot be bypassed via path casing on case-insensitive filesystems; existing rules are migrated at startup (GHSA-4vrc-935w-xxq2) reporter @FrogApprentice
|
||||
|
||||
**New Features**:
|
||||
- chunked uploads for large files like rclone `--owncloud-chunk-size` (#3112)
|
||||
|
||||
**Bug Fixes**:
|
||||
- keep requested permissions when creating a user (#3130)
|
||||
- add missing file modify logging to activity viewer (#3152)
|
||||
- fix(frontend): restore upload prompt close, tab-reload, and in-app navigation guards while uploads are active (including paused uploads)
|
||||
- fix(frontend): load the default source when browsing `/files/` with a single configured source ([#3178](https://github.com/gtsteffaniak/filebrowser/issues/3178)) (related: [#3143](https://github.com/gtsteffaniak/filebrowser/issues/3143), [#3144](https://github.com/gtsteffaniak/filebrowser/issues/3144))
|
||||
- Path picker list and thumbnails not working in shares in the markdown editor (#3223).
|
||||
- perf(indexing): single statx per Linux directory entry (#1890)
|
||||
|
||||
## v2.1.2
|
||||
|
||||
**Security**:
|
||||
- [High] New accounts named with ".." breaks out of scoped folder on creation (GHSA-c84q-q5g6-r47f) -- reporter @dylanzonix
|
||||
- [Medium] Fixed session token collision issue when multiple users login simultaneously. (GHSA-c9cr-w724-gqj3) (#3135) Thanks @dl-pt reporters: @Ishtiaque-Ahmed-Risat @AlexWhiplash @tonghuaroot
|
||||
|
||||
**Notes**:
|
||||
- Removed "Editor quick save" setting (`userDefaults.fileViewer.quickSave`), the save button is now always present in the editor toolbar. (#3122)
|
||||
- add Basque (eu) translation and fix Spanish (es) strings (#3118)
|
||||
|
||||
**Bug Fixes**:
|
||||
- fix(webdav): OPTIONS now advertises only methods actually supported and permitted for the user; the DAV header reports class 1 only since LOCK/UNLOCK are not enabled (#3110)
|
||||
- fix(download): revalidate single-file downloads so a changed file is not served stale (#3133)
|
||||
- Language switcher not working (#3114) (#3117)
|
||||
- Viewer showing a stale image after the file changed on disk: single-file downloads are now revalidated by the browser (#3132)
|
||||
|
||||
## v2.1.1
|
||||
|
||||
Note: this release also contains all changes from v2.0.10-beta changelog
|
||||
|
||||
**New Features**:
|
||||
- User and group picker with partial, case-insensitive search when creating access rules (#3085), assigning groups on user edit, and choosing members on group edit.
|
||||
- New `PUT /api/access/user-groups` endpoint that atomically replaces a user's group memberships.
|
||||
- feat(search): configure the advanced search result limit (#3058) @beemines
|
||||
- feat(listing): add sortable Date added and Type columns (#3052) @dweebzxx
|
||||
- feat(archive): suggest and preview ZIP filename encodings (#3024) @AnaTofuZ
|
||||
|
||||
**Notes**:
|
||||
- Group API breaking changes: `POST /api/access` returns 400 when `ruleCategory=group` names a nonexistent group or `ruleCategory=user` names a nonexistent user, group endpoints return 400/409 instead of 500 for client errors, and `PUT /api/access/group` accepts a `create` flag and returns `unknownMembers`. Existing database records are unaffected; no migration is required.
|
||||
- update halloween theme (#3055)
|
||||
- Add soft wrap option to the editor config (#3063)
|
||||
- add better screenshot view handling (#3059)
|
||||
- Update nl.json (#3089) @Stephan-P
|
||||
- update german translations (#3062) @fehnomenal
|
||||
- Update dev dependencies, add biome, fix types and linting (#3075) @Kurami32
|
||||
|
||||
**Bug Fixes**:
|
||||
- Groups assigned in the user edit prompt now refresh the Groups settings list.
|
||||
- Creating a group with an existing name now returns a conflict instead of silently overwriting its members.
|
||||
- Access rules can no longer create empty groups from typos; group rules must reference an existing group.
|
||||
- Empty group names are rejected and new group names must be 2-128 characters without control characters (existing records are unaffected); deleting or renaming a user now updates group memberships.
|
||||
- fix(auth): use login endpoint for existing TOTP authentication (#3074)
|
||||
- fix: apply theme toggles on anonymous public shares (#3049) @mvanhorn
|
||||
- fix(frontend): keep source named files in extractSourceFromPath (#3099) (#3095)
|
||||
- fix(auth): identify login fields for password autofill (#3077)
|
||||
- fix(backend): skip denied paths in multi-file downloads, 404 when none remain (#3100) @hippi345
|
||||
- fix: persist the user scope path chosen in the edit dialog (#3065) @mvanhorn
|
||||
|
||||
## v2.1.0
|
||||
|
||||
**Security**:
|
||||
- [Moderate] Conflicting upload responses no longer synchronously drain the request body before returning HTTP 409; the server now closes the body and marks the connection for closure, preventing denial-of-service from clients that never send EOF (CWE-400).
|
||||
- Share download links no longer create links with token, instead they link to the UI prompting for password before download. If a direct download is required, the `/api/share/direct` api exists and documented by swagger. (#2888)
|
||||
|
||||
**New Features**:
|
||||
- Added Storage Quotas
|
||||
- configurable by folder, source, user, and share.
|
||||
- Administrators can create, edit, view, and delete folder quotas through the interface.
|
||||
- Quota information now appears in source views, share details, sidebar progress bars, and folder prompts.
|
||||
- Enhancements to the editor (#2714):
|
||||
- Added a floating button to open a resizable split view with live editing for markdown files, the scrolling is synced in both sides and scrolling in single-view modes (the viewer and editor in non-split) now also tries to sync with each other so you don't lose context easily.
|
||||
- Added a toolbar: This toolbar is present on all the files.
|
||||
- JSON files have a button to toggle between formatted and minified versions (#2854) (#2566).
|
||||
- Markdown files have a richer toolbar with lots of quick actions, for example you can now browse, navigate and insert images more easily from your current source.
|
||||
- Added a prompt to configure some editor settings in UI, that prompt is accessible via the toolbar in the three-dots menu, there you can configure things like:
|
||||
- Word wrap, keybinds, autocompletion, scrollbar, etc.
|
||||
- The markdown viewer now supports rendering LaTeX Math and Chemistry formulas.
|
||||
- Improved and fixed some styles that weren't following the app light/dark theme (#2949).
|
||||
- Proxy auth group support: `groupsClaim`, `adminGroup`, and `userGroups` now control role-based access when the proxy sends a group/role header (#2755). Admin is determined by `adminGroup` only.
|
||||
- Moved `auth.adminUsername` and `auth.adminPassword` to `auth.methods.password` (legacy top-level keys are migrated automatically on load).
|
||||
- Support for `.elrc` (word-by-word lyrics), `.vtt`, `.srt` sidecar files for lyrics in audio files in the media player. (#2838)
|
||||
- Added setting to configure the placement of the prompts close button in profile settings (#2853) (#2595).
|
||||
- Added "New file templates". You can now add and configure pre-defined filenames + extension for the creation of new files in the context menu on profile settings! (#2881) (#1239).
|
||||
- sidebar links can be bulk edited as yaml (#1963) (settings > user management)
|
||||
- admins can customize more defaults:
|
||||
- share creation defaults and enforcements (#1692) (#2434) (#2279) (#2812) (settings > share management)
|
||||
- sidebar link defaults and enforcements (#2561) (settings > user management)
|
||||
- tool defaults and enforcement (settings > user management)
|
||||
- Add cli init CLI command (#2957)
|
||||
- Sort header in file listings inside prompts like move/copy/quick jump, is now sticky. Also added a button to show a search input and filter the items of the current listing (#3028).
|
||||
- Quick jump now scrolls and highlights the current item you are previewing (#3028).
|
||||
- When hovering `date` and `name` in the listings, will show up the formatted/raw date (the inverse of what you have configured to display) and the full filename (#3028) (#2623).
|
||||
- User groups can now be managed from the WebUI under `User Management` -> `Groups` and when editing a user (#2983) (#3040).
|
||||
|
||||
**Notes**:
|
||||
- An Admin can remove a `defautlEnabled` source for a user and it will remove removed until an admin adds it back.
|
||||
- Enhancements to cross-source copy/move permissions and behavior.
|
||||
- Improved resume upload behavior.
|
||||
- Play/pause on videos in mobile now is toggled by the button in the middle rather than the whole container (#2828).
|
||||
- improved video thumbnail generation speed and efficiency.
|
||||
- Added icons based on extension to the upload prompt and new file/folder/rename prompts (#2881).
|
||||
- Profile settings show minimal options by default. Full advanced profile settings can be shown by default by enabling `account.showAdvancedProfile`.
|
||||
- sidebar links/navigation is button group toggle for clearer visibility
|
||||
- Pop-up preview has 200ms debounce delay so it doesn't flash from moving the cursor across files quickly.
|
||||
- Added risc-v to official releases
|
||||
- Polished a bit some styles across various places of the UI (#3019) (#3028).
|
||||
- Moved `Access Management` under `User Management` in the UI (#3040).
|
||||
- improved developer experience with native vue webhook reloads.
|
||||
- improved ffmpeg handling and concurrency via library upgrades
|
||||
|
||||
**Bug Fixes**:
|
||||
- Undo in a fresh opened file on the editor was setting the file empty (#2714)
|
||||
- Added some missing styles in the markdown viewer (#2714)
|
||||
- Recaptcha not working (#1925) (#2861)
|
||||
- Fix disk-usage overstatement on virtiofs bind mounts (#2894)
|
||||
- Support non-ASCII share passwords (#2933)
|
||||
- Fall back to buffered copies when FUSE rejects fast paths (#2938)
|
||||
- Preserve deleted sidebar links across restarts (#2935)
|
||||
- scope padding to listing view (#2934)
|
||||
- Hide the Replace option on upload/create conflict prompts when the user lacks modify permission (or when a public share disallows replacements), so create-only users are not offered an action that the server rejects (#2837)
|
||||
- Preserve Ctrl-click file selection when keyboard state is stale (#2958) (#2923)
|
||||
- Avoid false stalls during parallel transfers (#2950) (#2948) thanks @gudcks0305
|
||||
- Cap source usage-bar percentage at 100% when indexed size exceeds partition total (#2761) (#2238)
|
||||
- On Linux, source partition totals sum distinct filesystems mounted under the source root (nested mounts) so usage bars match indexed content (#2761)
|
||||
- Context menu stuck when opened from a listing in a prompt like move/copy or search (#3028).
|
||||
|
||||
## v2.0.10
|
||||
|
||||
**Security**:
|
||||
- [High] Restricted custom API tokens (`minimal=false`) can no longer be upgraded to a full-permission session via `POST /api/auth/renew`; renew accepts only web session tokens registered in `hashed_tokens` (GHSA-6gr6-5qpq-888p) -- thanks @tao0845.
|
||||
- [High] TOTP re-enrollment via `POST /api/auth/otp/generate` and `POST /api/auth/otp/verify` no longer allows anonymous callers to replace an existing second factor using only the account password; reset or replace requires an authenticated self or admin session (first-time enrollment without MFA unchanged) (GHSA-qx86-4v5r-26g5) -- thanks @tao0845.
|
||||
- [Low] Public share lyrics and subtitle media routes now honor the share's file-viewer setting, download disable flag, and download limits, matching the public download route (GHSA-p7x3-p5jj-9xfh) -- thanks Yves Soete of Blacksight LLC. @yssoe
|
||||
|
||||
**New Features**:
|
||||
- Require password change at next login for password-based users: new user setting `requirePasswordChange` (user defaults + per-user admin toggle), login blocked until the user sets a new password; bootstrap admins with a generated initial password get this automatically ([#2977](https://github.com/gtsteffaniak/filebrowser/issues/2977)). Generated bootstrap passwords use a speakable `word-xxxxx-xx` form (random word from a fixed list plus a 5-character and 2-character code) instead of a long hex string, for easier handoff before the forced change.
|
||||
- Config YAML expands `$VAR` and `${VAR}` so values such as `userPassword: "${FILEBROWSER_LDAP_USER_PASSWORD}"` work as documented (#3042).
|
||||
|
||||
**Notes**:
|
||||
- Sidebar navigation tree rows are real hyperlinks: middle-click, Ctrl/Cmd+click, and Shift+click use the browser’s default new-tab or new-window behavior.
|
||||
- Sidebar source links can now switch between aggregated usage (default) and a root-filesystem-only view via a new "Limit disk usage to source filesystem" toggle.
|
||||
- [docker] upgraded ffmpeg from 9.0 to 9.0.2
|
||||
- CLI setup command no longer produces full config, instead a minimal config.yaml with comments.
|
||||
|
||||
**Bugfixes**:
|
||||
- Fixed slow or broken file listing when `http.baseURL` is a subpath (for example `/files/`): the default source redirect navigated to `/files/{source}` on top of the app base URL, producing `/files/files/{source}` and resolving the wrong storage source until the route recovered.
|
||||
- Anonymous visitors on public shares could not play inline video or audio: `GET /public/api/media/stream` returned 403 because view grants were validated before share context was fully hydrated; playback now works when download and the file viewer are allowed ([#3041](https://github.com/gtsteffaniak/filebrowser/issues/3041)).
|
||||
- Fixed inflated disk usage for sources spanning multiple ZFS datasets (or btrfs subvolumes): shared-pool filesystems are now grouped by pool and counted once, instead of multiplying capacity by the number of mounted datasets (#3025) (#2997) (#2761).
|
||||
- LDAP `userGroups` matching now accepts CN-only config values against full `memberOf` DNs and compares case-insensitively, instead of failing the shared auth helper with strict string equality (#3044).
|
||||
- OIDC session expires despite tokenExpirationHours (#3006).
|
||||
- Media playback becomes stuck after opening a failing media file since v2.0.8-beta (#3031)
|
||||
|
||||
## v2.0.9
|
||||
|
||||
**Security**:
|
||||
@@ -26,6 +228,7 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- SQLite index and application databases configure `busy_timeout` on every pooled connection, and busy/locked detection now uses driver result codes; index batch writes and maintenance no longer report success when the DB stays busy, and index cache reads return busy errors instead of empty results so listing can fall back to the filesystem.
|
||||
|
||||
## v2.0.8
|
||||
|
||||
**Security**:
|
||||
- [Critical] A forged JWT could authenticate as any known `belongsTo`. Auth signing keys are now persisted in the application database and JWT validation fails closed when no key is configured. (GHSA-8f9r-wg7w-pfw) (#2987) Thanks @d3do-23 and @whoamis3c.
|
||||
- [Medium] Public upload shares with replacements disabled (`allowReplacements=false`) now reject overwrites when clients send `override=true`. (GHSA-3846-gh75-gp3m) Thanks @d3do-23
|
||||
@@ -50,7 +253,7 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- Add cli init CLI command (#2957)
|
||||
|
||||
**Notes**:
|
||||
- Pop-up preview has 200ms debounce delay so it doesn't flash from moving the cursor across files quickly.
|
||||
- Pop-up preview has 200ms debounce delay so it doesn't flash from moving the cursor across files quickly.
|
||||
- Added risc-v to official releases
|
||||
|
||||
**Bugfixes**:
|
||||
@@ -130,6 +333,7 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
|
||||
- OnlyOffice is inaccessible on password-protected shares (#2811)
|
||||
- FFmpeg 9.0 incorrectly detected as below minimum 5.0.0 on Windows (#2820) -- thanks @yzxcj797
|
||||
- Queue buttons not clickable in the desktop panel in audio files.
|
||||
|
||||
## v2.0.1
|
||||
|
||||
@@ -203,7 +407,7 @@ This version represents the most significant change to date. It **requires** bot
|
||||
- opt-in feature to send deployment analytics to filebrowser quantum developer servers
|
||||
- anonymized with a viewer so users can see what info would be sent.
|
||||
- if opt-in, every month a snapshot of your deployment config would be sent to developer servers
|
||||
- this will help me know what features are being used and what versions everyone is on over time. I will also provide a public dashboard with this information in the future.
|
||||
- this will help me know what features are being used and what versions everyone is on over time. I will also provide a public dashboard with this information in the future.
|
||||
- WebDAV now supports set modification time via the `X-OC-Mtime` header for clients that support it (#2626). See [WebDAV docs](https://filebrowserquantum.com/en/docs/features/webdav/).
|
||||
- Copy operations now preserve their original modification times (#2642) (#2647):
|
||||
- WebUI preserves both, files and directories.
|
||||
@@ -383,14 +587,14 @@ This version represents the most significant change to date. It **requires** bot
|
||||
- Improved style of drag and drop into listing view (#2407)
|
||||
- Edit Sidebar links has new "show tools in sidebar" toggle and all users have this enabled by default. can be disabled via for new users `userDefaults.sidebar.showTools: false`
|
||||
- Update user defaults ordering (#1140)
|
||||
- Save view modes and sizes into local storage instead of db (#2301)
|
||||
- Save view modes and sizes into local storage instead of db (#2301)
|
||||
|
||||
**BugFixes**:
|
||||
- Blue overlay when using gestures in video files on mobile (#2360)
|
||||
- Playback queue wasn't updating when changing of folder (#2360)
|
||||
- Added missing `UserDefaults.listing.showCopyPath` option to config file (#2364)
|
||||
- Delete prompt thumbnail didn't worked when using it with the `delete` shortcut. (#2407)
|
||||
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
|
||||
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
|
||||
- Drag and drop style state was stuck until page reload (#2407)
|
||||
- Navigate close settings shows "something went wrong" (#2047)
|
||||
|
||||
@@ -536,7 +740,7 @@ This version represents the most significant change to date. It **requires** bot
|
||||
- Cannot edit shared file in OnlyOffice #2143
|
||||
- PWA install button disappeared (#2086)
|
||||
- Deleting a root folder was possible #2128
|
||||
- PUT resource api errors if action against a folder (#2153)
|
||||
- PUT resource api errors if action against a folder (#2153)
|
||||
- LDAP authentication issue if a password caontains @ symbol (#2154)
|
||||
- Share banner seems to be not working for custom urls (#2120)
|
||||
|
||||
@@ -570,7 +774,7 @@ Note: a potentially breaking change for docker users: \the default user is now "
|
||||
- New Sidebar Features
|
||||
- Sidebar tree navigation (#2006) (#350)
|
||||
- Source usage to be customized to show os-reported values rather than calculated. This can be changed per source by editing the source link in the sidebar. (#1266) (#982)
|
||||
- Archive/Unarchive actions in UI (#1252) (#335) (#1569)
|
||||
- Archive/Unarchive actions in UI (#1252) (#335) (#1569)
|
||||
- new api to archive/unarchive files on the server
|
||||
- requires `create` user permissions
|
||||
- archiving actions respect `server.maxArchiveSize`
|
||||
@@ -733,7 +937,7 @@ Note: a potentially breaking change for docker users: \the default user is now "
|
||||
## v1.2.1-beta
|
||||
|
||||
**New Features**:
|
||||
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
|
||||
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
|
||||
|
||||
**Notes**:
|
||||
- upgraded imaging package and improved thumbnail generation performance. (#1797) (#1850)
|
||||
@@ -836,7 +1040,7 @@ Sorry for the delay -- a lot of effort went into this release, specifically abou
|
||||
- warning for low free space below 20GB
|
||||
- warning if read/write latency is slow
|
||||
- fatal for any errors reading or writing to the directory
|
||||
- links to official docs for more info
|
||||
- links to official docs for more info
|
||||
- access rule changes:
|
||||
- denied folders won't show up in parent directory listing view (#1684)
|
||||
- tools will respect access rules
|
||||
@@ -871,7 +1075,7 @@ Sorry for the delay -- a lot of effort went into this release, specifically abou
|
||||
|
||||
**BugFixes**:
|
||||
- better index status updates, fixing delays #1649
|
||||
- fixed long load times for listings with media info due sequential processing of files.
|
||||
- fixed long load times for listings with media info due sequential processing of files.
|
||||
- downloaded files always included `utf-8` in filename #1671
|
||||
- custom sidebar links allow external links like `https://google.com`
|
||||
- html title not populated correctly for links #1676
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,7 +86,7 @@ See the [Official Docs](https://filebrowserquantum.com/). Contributions are welc
|
||||
## Comparison Chart
|
||||
Application Name | <img width="48" alt="" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" > Quantum | <img width="48" alt="" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" alt="" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" alt="" src="https://cdn.iconscout.com/icon/free/png-256/free-google-drive-logo-icon-svg-download-png-2476481.png" > Google_Drive | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
|
||||
--- | --- | --- | --- | --- | --- | --- |
|
||||
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Filesystem support | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
@@ -95,7 +95,7 @@ Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
|
||||
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
|
||||
@@ -109,25 +109,25 @@ Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Single sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Mobile App | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
|
||||
API documentation page | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
|
||||
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
tags support | :construction: | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Event-based notifications | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Metrics | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
file space quotas | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
Metrics | :construction: | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
file space quotas | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Themes | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
activity log | ✅ | ❌ | ✅ | ✅ | ✅ | ✅
|
||||
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Starred/pinned files | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
Starred/pinned files | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM gtstef/ffmpeg:9.0-decode AS ffmpeg
|
||||
FROM gtstef/ffmpeg:9.0.2-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
|
||||
@@ -4,4 +4,4 @@ WORKDIR /app/frontend
|
||||
COPY frontend/package-lock.json /tmp/package-lock.json
|
||||
RUN PW_VERSION=$(node -p "require('/tmp/package-lock.json').packages['node_modules/@playwright/test'].version") \
|
||||
&& npm i "@playwright/test@${PW_VERSION}" oauth2-mock-server @types/node
|
||||
RUN npx playwright install --with-deps chromium
|
||||
RUN npx playwright install --with-deps chromium-headless-shell
|
||||
@@ -4,10 +4,8 @@ http:
|
||||
<<: *http-main
|
||||
frontend:
|
||||
<<: *frontend-main
|
||||
|
||||
auth:
|
||||
<<: *auth-main
|
||||
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
|
||||
@@ -13,3 +13,10 @@ server:
|
||||
name: "playwright"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
userDefaults:
|
||||
listing:
|
||||
viewMode: "normal"
|
||||
# Size 4: switch-view "gallery" family resolves to icons (not gallery); list family → list.
|
||||
gallerySize: 3
|
||||
account:
|
||||
showAdvancedProfile: true
|
||||
@@ -12,7 +12,7 @@ import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
|
||||
*/
|
||||
export default defineConfig<PlaywrightFixtureOptions>({
|
||||
globalSetup: "./tests/playwright/screenshots-setup.ts",
|
||||
timeout: 5000,
|
||||
timeout: 60_000,
|
||||
testDir: "./tests/playwright/screenshots",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
FILEBROWSER_DEVMODE=true
|
||||
+10
-4
@@ -1,16 +1,22 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
env_files = [".air.env"]
|
||||
|
||||
[build]
|
||||
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
|
||||
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
|
||||
cmd = "bash ./scripts/air-build.sh"
|
||||
entrypoint = "./tmp/filebrowser"
|
||||
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
|
||||
args_bin = ["-c", "test_config.yaml"]
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs"]
|
||||
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs", "internal/preview/gofitzinclude"]
|
||||
poll = true
|
||||
poll_interval = 1000
|
||||
log = "air.log"
|
||||
delay = 1000 # ms
|
||||
|
||||
[build.windows]
|
||||
cmd = "bash ./scripts/air-build.sh"
|
||||
entrypoint = "./tmp/filebrowser.exe"
|
||||
|
||||
[log]
|
||||
time = true
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
|
||||
cmd = "go build -o ./tmp/filebrowser.exe --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
|
||||
entrypoint = "./tmp/filebrowser.exe"
|
||||
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser.exe -c test_config.yaml"
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["tmp", "vendor", "internal\\web\\dist", "internal\\web\\embed", "internal\\preview\\testdata", "internal\\preview\\thumbnails", "internal\\preview\\heic", "swagger\\docs"]
|
||||
exclude_unchanged = true
|
||||
follow_symlink = false
|
||||
poll = true
|
||||
poll_interval = 1000
|
||||
log = "air.log"
|
||||
delay = 1000 # ms
|
||||
|
||||
[log]
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
+20
-20
@@ -45,7 +45,7 @@ require (
|
||||
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
|
||||
@@ -101,16 +101,16 @@ require (
|
||||
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 @@ require (
|
||||
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 @@ require (
|
||||
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
|
||||
@@ -201,14 +201,14 @@ require (
|
||||
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/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 @@ require (
|
||||
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
|
||||
|
||||
@@ -56,6 +56,8 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/go-check-sumtype v0.4.0 h1:gd4mE4QsA62sXlnFIs8GUv8/0xH3DOUqTkYQR1l2v28=
|
||||
github.com/alecthomas/go-check-sumtype v0.4.0/go.mod h1:aYYllI8IXNjo7ZYUzI2+wyLRECMg3zo+8CZj9JTBAWU=
|
||||
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 h1:dXPJ+uOJLWb8rjlAuLX7JPiRJtZKw/53Ol+WxdDJ1Eo=
|
||||
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606/go.mod h1:ZH31IJyNpxpStxP2ayCcPQz+3T2G/OnxIQqe4LWvSDw=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
@@ -122,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=
|
||||
@@ -138,6 +142,8 @@ github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1Di
|
||||
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 h1:/hSCZO1puetbGd2JikHKN0nAcb2mFevzxJElFoduhaw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16/go.mod h1:JFvmQPZh3uAu7PpSZU91bcR0BrIy24HhW+0EsW1uGxE=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 h1:ZGHc3eaN2TIhkpwRibtr8N9jLQJOY1rh6GEByRoRmp4=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9/go.mod h1:D9SVXVpAsD1pP6xR6BiERbe4+dUU6EW9US1TFrNutGA=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
@@ -284,16 +290,24 @@ 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=
|
||||
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
|
||||
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 h1:WL8YKrt3UbOBqSRU7GpP5BTtQTMWtVtj+mfPijgZeIg=
|
||||
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792/go.mod h1:te5hX0dW4C5r6YbXs+6ysNr8Q5UTmdIqGbb+mlFiYmA=
|
||||
github.com/golangci/golangci-lint/v2 v2.13.2 h1:bCyq3E4vo9qwzifjpzJqYndEt7Ncva80qkk8G2B4TXU=
|
||||
github.com/golangci/golangci-lint/v2 v2.13.2/go.mod h1:5xaMd1kAxV7GSBPEyngw8gnnjoRocqW9UVKdBR6627w=
|
||||
github.com/golangci/golangci-lint/v2 v2.14.0 h1:ot8QffRa4LzAAEgtvNYrVs9esxQ7xoTwoAv6uhp4ngA=
|
||||
github.com/golangci/golangci-lint/v2 v2.14.0/go.mod h1:amHimCzNDP1GfVno3hIiNssjvqXmuW+CPZAHOdR4cIc=
|
||||
github.com/golangci/golangci-lint/v2 v2.15.0 h1:w94zf8HAYI7hm3164kwKsvZRuqXvY+YxeFoTbu1OLg8=
|
||||
github.com/golangci/golangci-lint/v2 v2.15.0/go.mod h1:/FRI304IxqDVhX2o3ZZ0qp8Vvz8egJdzCY5A4GjvmTU=
|
||||
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
|
||||
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
|
||||
github.com/golangci/golines v0.16.0 h1:xKdwzRvwzLd7V131B/dv90vUiEoTA0vQybdeqperPOg=
|
||||
github.com/golangci/golines v0.16.0/go.mod h1:mCMgRQHiCvuALJOeaNtylu09zbzvS9Ka01dfvLLJios=
|
||||
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
|
||||
github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
|
||||
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
|
||||
@@ -302,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=
|
||||
@@ -359,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=
|
||||
@@ -372,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=
|
||||
@@ -410,12 +430,18 @@ github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
|
||||
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mgechev/revive v1.16.0 h1:9OZVO8xOPUwIYynplHXv7liMllPQ/BTNZLp07sM/Im0=
|
||||
github.com/mgechev/revive v1.16.0/go.mod h1:nT854jMmvQsS/qqxEaMPM4pJSUpxhcPoHM788ZxDvdQ=
|
||||
github.com/mgechev/revive v1.17.0 h1:hwTTVC3svSv9X2FA2hiYT17Uws+lfxDWhPxzBZZCREg=
|
||||
github.com/mgechev/revive v1.17.0/go.mod h1:uiVNUF8ruVh4WHm/aPs7kKEeuo+m6oO3tuOfniyzP/g=
|
||||
github.com/mgechev/revive v1.17.1 h1:z8hV0L3uJ6yrwOMnJbkc5qBJpxXa2AxYKFKERGJyA4s=
|
||||
github.com/mgechev/revive v1.17.1/go.mod h1:FRd64pJwWSEE6j8ESoaX0PixPzjh2rpNSfckbEu3rIE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
@@ -436,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=
|
||||
@@ -567,12 +598,18 @@ github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
|
||||
github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c h1:d/FUP8GD4QiY/rdFzZmBu3KqL9eBNVzMu7W/5pGkaMM=
|
||||
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
|
||||
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 h1:HECVafB2AKntWWRfsiQ4gdunLx5WOYQzFdCsxqOrzp8=
|
||||
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2/go.mod h1:JLoPVtROtNgJ99yXTIxuGNttvmJa85tkJAO6lr05qBA=
|
||||
github.com/timonwong/loggercheck v0.12.0 h1:7Cb9XGLJZSWYNjgL459DIhpSiEHupD+MLQ6vaN2Pl3g=
|
||||
github.com/timonwong/loggercheck v0.12.0/go.mod h1:Wa6Y+SJHQR01mViZlLLY5GAZVOWOLNMbkXddMjFWQCs=
|
||||
github.com/timonwong/loggercheck v0.13.0 h1:6amIOmLLPFYt24rXUbNWzcJeNGuJc+3c31tZtO85VHs=
|
||||
github.com/timonwong/loggercheck v0.13.0/go.mod h1:thn3allpox0fI5JeUWTCwiwp45peQxCZZHkHuA1Oc6I=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.13.0 h1:jYbLNs8PDkzGCY6d5LUzJdY7Bq//CTlb2HxZGV5z0JY=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.13.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
|
||||
github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI=
|
||||
@@ -583,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=
|
||||
@@ -651,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=
|
||||
@@ -662,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=
|
||||
@@ -671,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=
|
||||
@@ -686,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=
|
||||
@@ -708,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=
|
||||
@@ -726,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.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
|
||||
config.Auth.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:")
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(testutil.Main(m))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -200,9 +200,9 @@ func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shu
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to create sub filesystem: %v", err)
|
||||
}
|
||||
fileutils.InitAssetFS(subAssets, true)
|
||||
fileutils.InitAssetFS(subAssets, true, false)
|
||||
} else {
|
||||
fileutils.InitAssetFS(nil, false)
|
||||
fileutils.InitAssetFS(nil, false, settings.Env.IsDevMode)
|
||||
}
|
||||
|
||||
// Start preview service
|
||||
|
||||
+3
-6
@@ -74,10 +74,7 @@ func validateUserInfo(newDB bool) {
|
||||
if bumpToNewestVersion(user, tokenBackfillFailed) {
|
||||
changedFields = append(changedFields, "version")
|
||||
}
|
||||
adminUser := settings.Config.Auth.AdminUsername
|
||||
if adminUser == "" {
|
||||
adminUser = "admin"
|
||||
}
|
||||
adminUser := settings.PasswordAdminUsername()
|
||||
if user.Username == adminUser && user.Permissions.Admin {
|
||||
adminPerms := settings.AdminPerms()
|
||||
if user.Permissions.Share != adminPerms.Share || user.Permissions.Api != adminPerms.Api {
|
||||
@@ -90,7 +87,7 @@ func validateUserInfo(newDB bool) {
|
||||
if user.Username == adminUser && settings.ConfigTriggersAdminPasswordReset() && user.LoginMethod == users.LoginMethodPassword {
|
||||
logger.Info("Resetting admin user to default username and password.")
|
||||
user.Permissions = settings.AdminPerms()
|
||||
user.Password = settings.Config.Auth.AdminPassword
|
||||
user.Password = settings.PasswordAdminPassword()
|
||||
user.LoginMethod = users.LoginMethodPassword
|
||||
changedFields = append(changedFields, "permissions", "password", "loginMethod")
|
||||
changePass = true
|
||||
@@ -138,7 +135,7 @@ func dedupeFields(fields []string) []string {
|
||||
}
|
||||
|
||||
func updateUserScopes(user *users.User) bool {
|
||||
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes)
|
||||
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes, user.DeclinedDefaultSources)
|
||||
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
|
||||
user.BackendScopes = newScopes
|
||||
return changed
|
||||
|
||||
@@ -18,7 +18,6 @@ server:
|
||||
|
||||
userDefaults:
|
||||
fileViewer:
|
||||
editorQuickSave: false
|
||||
disableOnlyOfficeExt: ".md .txt .pdf"
|
||||
disableViewingExt: ""
|
||||
sidebar:
|
||||
@@ -53,6 +52,7 @@ userDefaults:
|
||||
lockPassword: false
|
||||
disableSettings: false
|
||||
disableUpdateNotifications: false
|
||||
showAdvancedProfile: false
|
||||
permissions:
|
||||
api: false
|
||||
admin: false
|
||||
|
||||
+25
-26
@@ -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,26 +19,27 @@ require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v0.5.2
|
||||
github.com/gtsteffaniak/go-ffmpeg 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.59.0
|
||||
modernc.org/sqlite v1.60.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -48,14 +49,14 @@ 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-openapi/jsonpointer v1.0.1 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.2 // 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
|
||||
github.com/go-openapi/swag/conv v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
|
||||
@@ -64,12 +65,12 @@ require (
|
||||
github.com/go-openapi/swag/stringutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.29.2 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/locales v0.14.2 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.3.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 // indirect
|
||||
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
@@ -78,24 +79,23 @@ require (
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.1.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
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/tinylib/msgp v1.6.5 // indirect
|
||||
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
|
||||
)
|
||||
@@ -103,5 +103,4 @@ require (
|
||||
exclude (
|
||||
github.com/alecthomas/go-check-sumtype v0.5.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.2.0
|
||||
)
|
||||
+58
-60
@@ -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,14 +65,14 @@ 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-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg=
|
||||
github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY=
|
||||
github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc=
|
||||
github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
|
||||
github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
|
||||
github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
|
||||
github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8=
|
||||
github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ=
|
||||
github.com/go-openapi/jsonreference v1.0.3/go.mod h1:OJSlMXHysgH272D9z2tiy2MGe5A9vQLHaCPb6KbYdE4=
|
||||
github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk=
|
||||
github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
@@ -94,12 +94,12 @@ github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAY
|
||||
github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs=
|
||||
github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8=
|
||||
github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho=
|
||||
github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY=
|
||||
github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
|
||||
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
|
||||
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
|
||||
@@ -119,8 +119,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
||||
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 h1:m+G0ip1+N4CF0ex34SeojAon6htIIBwvzsyXNx1fGWg=
|
||||
github.com/golang/geo v0.0.0-20260818125358-b200a1149890/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb h1:z//sB+BhCYNvUrk19E8aWqNx+BBom8p/uD/nh95N/5U=
|
||||
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
@@ -143,10 +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 v0.5.2 h1:KVnKM3zewY9yeZVd59Hb0/10gSbhYf01yRM5RftXGEU=
|
||||
github.com/gtsteffaniak/go-ffmpeg v0.5.2/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.0.0 h1:dUWvlZmWnkiujDtIMLGwrpfSAOnM+IOtk93HXF6HR0U=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.0.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=
|
||||
@@ -173,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,10 +187,10 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
|
||||
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||
github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
|
||||
github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -219,8 +217,8 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
|
||||
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
|
||||
github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
@@ -235,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=
|
||||
@@ -253,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=
|
||||
@@ -272,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=
|
||||
@@ -313,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=
|
||||
@@ -335,8 +333,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
@@ -162,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,
|
||||
@@ -197,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) {
|
||||
@@ -209,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,
|
||||
@@ -217,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,
|
||||
@@ -287,6 +312,79 @@ func RecordAccessDelete(r *http.Request, actor *Actor, source, path string, chan
|
||||
})
|
||||
}
|
||||
|
||||
func RecordQuotaCreate(r *http.Request, actor *Actor, source, path string, changes []activitydb.FieldChange) {
|
||||
RecordQuotaMutation(r, actor, activitydb.EventQuotaCreate, source, path, changes)
|
||||
}
|
||||
|
||||
func RecordQuotaUpdate(r *http.Request, actor *Actor, source, path string, changes []activitydb.FieldChange) {
|
||||
RecordQuotaMutation(r, actor, activitydb.EventQuotaUpdate, source, path, changes)
|
||||
}
|
||||
|
||||
func RecordQuotaDelete(r *http.Request, actor *Actor, source, path string, changes []activitydb.FieldChange) {
|
||||
RecordQuotaMutation(r, actor, activitydb.EventQuotaDelete, source, path, changes)
|
||||
}
|
||||
|
||||
func RecordQuotaMutation(r *http.Request, actor *Actor, eventType activitydb.EventType, source, path string, changes []activitydb.FieldChange) {
|
||||
RecordUser(r, actor, activitydb.Entry{
|
||||
EventType: eventType,
|
||||
Source: source,
|
||||
Path: path,
|
||||
Details: activitydb.Details{
|
||||
Source: source,
|
||||
Path: path,
|
||||
Changes: changes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func quotaFolderChanges(q quota.FolderQuota) []activitydb.FieldChange {
|
||||
changes := []activitydb.FieldChange{
|
||||
{Field: "limitBytes", To: strconv.FormatInt(q.LimitBytes, 10)},
|
||||
{Field: "meter", To: q.Meter},
|
||||
}
|
||||
if q.UserID > 0 {
|
||||
changes = append(changes, activitydb.FieldChange{
|
||||
Field: "userId",
|
||||
To: strconv.FormatUint(q.UserID, 10),
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func QuotaFolderCreateChanges(q quota.FolderQuota) []activitydb.FieldChange {
|
||||
return quotaFolderChanges(q)
|
||||
}
|
||||
|
||||
func QuotaFolderDeleteChanges(q quota.FolderQuota) []activitydb.FieldChange {
|
||||
return quotaFolderChanges(q)
|
||||
}
|
||||
|
||||
func QuotaFolderUpdateChanges(before, after quota.FolderQuota) []activitydb.FieldChange {
|
||||
var changes []activitydb.FieldChange
|
||||
if before.LimitBytes != after.LimitBytes {
|
||||
changes = append(changes, activitydb.FieldChange{
|
||||
Field: "limitBytes",
|
||||
From: strconv.FormatInt(before.LimitBytes, 10),
|
||||
To: strconv.FormatInt(after.LimitBytes, 10),
|
||||
})
|
||||
}
|
||||
if before.Meter != after.Meter {
|
||||
changes = append(changes, activitydb.FieldChange{
|
||||
Field: "meter",
|
||||
From: before.Meter,
|
||||
To: after.Meter,
|
||||
})
|
||||
}
|
||||
if before.UserID != after.UserID {
|
||||
changes = append(changes, activitydb.FieldChange{
|
||||
Field: "userId",
|
||||
From: strconv.FormatUint(before.UserID, 10),
|
||||
To: strconv.FormatUint(after.UserID, 10),
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func AccessRuleCreateChanges(allow bool, ruleCategory, value string) []activitydb.FieldChange {
|
||||
ruleType := "deny"
|
||||
if allow {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -25,8 +23,6 @@ import (
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var reDuration = regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
|
||||
|
||||
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
|
||||
var CheckPermissionsFunc = func(opts utils.FileOptions, user *users.User) (string, string, error) {
|
||||
return checkPermissionsImpl(opts, user, svc())
|
||||
@@ -50,7 +46,7 @@ func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileO
|
||||
wg.Go(func() {
|
||||
fullPath := response.RealPath + "/" + fileItem.Name
|
||||
if isAudio {
|
||||
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, false, sharedFFmpegService); err != nil {
|
||||
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, sharedFFmpegService); err != nil {
|
||||
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
}
|
||||
} else {
|
||||
@@ -75,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)
|
||||
@@ -334,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
|
||||
@@ -393,7 +403,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, false, nil)
|
||||
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
@@ -431,57 +441,10 @@ func generateOfficeId(realPath string) string {
|
||||
return key
|
||||
}
|
||||
|
||||
// Parses raw LRC text into a slice of Lyric.
|
||||
// Lines with [hh:mm:ss.xx] (timestamps) will be parsed to ms for synced lyrics
|
||||
// The ones without timestamp are kept as unsynced (timestamp 0).
|
||||
func parseLRC(raw string) []iteminfo.Lyric {
|
||||
var lines []iteminfo.Lyric
|
||||
scanner := bufio.NewScanner(strings.NewReader(raw))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
matches := reDuration.FindStringSubmatch(line)
|
||||
if len(matches) == 6 {
|
||||
// Hours default to 0 (if not present in the timestamps)
|
||||
h := 0
|
||||
if matches[1] != "" {
|
||||
h, _ = strconv.Atoi(matches[1])
|
||||
}
|
||||
min, _ := strconv.Atoi(matches[2])
|
||||
sec, _ := strconv.Atoi(matches[3])
|
||||
|
||||
msStr := matches[4]
|
||||
// Normalise to 3 digits, some songs have more than 3 digits in the milliseconds
|
||||
switch {
|
||||
case len(msStr) < 3:
|
||||
msStr += strings.Repeat("0", 3-len(msStr))
|
||||
case len(msStr) > 3:
|
||||
msStr = msStr[:3]
|
||||
}
|
||||
ms, _ := strconv.Atoi(msStr)
|
||||
timestamp := int64((h*3600+min*60+sec)*1000 + ms)
|
||||
text := strings.TrimSpace(matches[5])
|
||||
lines = append(lines, iteminfo.Lyric{
|
||||
Text: text,
|
||||
Timestamp: timestamp,
|
||||
})
|
||||
} else {
|
||||
// Line without a valid timestamp – treat as unsynced (timestamp 0)
|
||||
lines = append(lines, iteminfo.Lyric{
|
||||
Text: line,
|
||||
Timestamp: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
|
||||
// and optionally extracts duration using the ffmpeg service with concurrency control
|
||||
// If ffmpegService is nil, a new service will be created (for backward compatibility)
|
||||
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, getLyrics bool, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -518,28 +481,7 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
item.Metadata.Track = track
|
||||
|
||||
// Check if lyrics are available without extract them
|
||||
if rawLyrics := m.Lyrics(); rawLyrics != "" {
|
||||
item.Metadata.HasLyrics = true
|
||||
} else {
|
||||
// Check for sidecar .lrc file
|
||||
dir := filepath.Dir(realPath)
|
||||
base := filepath.Base(realPath)
|
||||
ext := filepath.Ext(base)
|
||||
nameWithoutExt := strings.TrimSuffix(base, ext)
|
||||
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
|
||||
if _, err := os.Stat(lrcPath); err == nil {
|
||||
item.Metadata.HasLyrics = true
|
||||
}
|
||||
}
|
||||
|
||||
if getLyrics {
|
||||
lyrics, err := ExtractLyrics(realPath)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract lyrics for %s: %v", realPath, err)
|
||||
} else if len(lyrics) > 0 {
|
||||
item.Metadata.Lyrics = lyrics
|
||||
}
|
||||
}
|
||||
item.Metadata.HasLyrics = hasLyricsSidecar(realPath) || m.Lyrics() != ""
|
||||
|
||||
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
|
||||
// This respects concurrency limits and gracefully handles missing ffmpeg
|
||||
@@ -572,35 +514,91 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns lyrics from an audio file (from embedded tags or from a .lrc file with the same name).
|
||||
func ExtractLyrics(realPath string) ([]iteminfo.Lyric, error) {
|
||||
var lyricsSidecarExts = []string{".elrc", ".lrc", ".srt", ".vtt"}
|
||||
|
||||
func lyricsSidecar(dir, nameWithoutExt, ext string) (path string, ok bool) {
|
||||
sidecarPath := filepath.Join(dir, nameWithoutExt+ext)
|
||||
info, err := os.Lstat(sidecarPath)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", false
|
||||
}
|
||||
if info.Size() > 15*1024*1024 { // 15MB
|
||||
return "", false
|
||||
}
|
||||
return sidecarPath, true
|
||||
}
|
||||
|
||||
func findLyricsSidecar(realPath string) (data []byte, ext string, ok bool) {
|
||||
dir := filepath.Dir(realPath)
|
||||
base := filepath.Base(realPath)
|
||||
nameWithoutExt := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
for _, ext = range lyricsSidecarExts {
|
||||
sidecarPath, candOk := lyricsSidecar(dir, nameWithoutExt, ext)
|
||||
if !candOk {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(sidecarPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
continue
|
||||
}
|
||||
return data, strings.TrimPrefix(ext, "."), true
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
func hasLyricsSidecar(realPath string) bool {
|
||||
dir := filepath.Dir(realPath)
|
||||
base := filepath.Base(realPath)
|
||||
nameWithoutExt := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
for _, ext := range lyricsSidecarExts {
|
||||
sidecarPath, ok := lyricsSidecar(dir, nameWithoutExt, ext)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(sidecarPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := f.Read(buf)
|
||||
if n > 0 && strings.TrimSpace(string(buf[:n])) != "" {
|
||||
f.Close()
|
||||
return true
|
||||
}
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Returns raw lyrics from an audio file (from embedded tags or from sidecar files with the same name).
|
||||
func ExtractLyrics(realPath string) (string, string, error) {
|
||||
if data, ext, ok := findLyricsSidecar(realPath); ok {
|
||||
return string(data), ext, nil
|
||||
}
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
m, err := tag.ReadFrom(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var lyrics []iteminfo.Lyric
|
||||
if raw := m.Lyrics(); raw != "" {
|
||||
lyrics = parseLRC(raw)
|
||||
return raw, "embedded", nil
|
||||
}
|
||||
if len(lyrics) == 0 {
|
||||
// Check for sidecar .lrc file
|
||||
dir := filepath.Dir(realPath)
|
||||
base := filepath.Base(realPath)
|
||||
ext := filepath.Ext(base)
|
||||
nameWithoutExt := strings.TrimSuffix(base, ext)
|
||||
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
|
||||
if data, err := os.ReadFile(lrcPath); err == nil {
|
||||
lyrics = parseLRC(string(data))
|
||||
}
|
||||
}
|
||||
return lyrics, nil
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// extractVideoMetadata extracts duration and codec info from video files using ffprobe.
|
||||
@@ -645,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 {
|
||||
@@ -912,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
|
||||
@@ -964,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
|
||||
}
|
||||
|
||||
@@ -447,6 +447,7 @@ var types = map[string]string{
|
||||
".vtt": "text/subtitle-vtt",
|
||||
".ssa": "text/subtitle-ssa",
|
||||
".lrc": "text/lyrics",
|
||||
".elrc": "text/lyrics",
|
||||
".sbv": "text/plain",
|
||||
".ssi": "text/x-server-parsed-html",
|
||||
".ssm": "application/streamingmedia",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,13 @@ func (d dirFS) Open(name string) (fs.File, error) {
|
||||
|
||||
// InitAssetFS initializes the asset filesystem for the application
|
||||
// This should be called once during startup before http or preview services start
|
||||
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
|
||||
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool, devMode bool) {
|
||||
if useEmbedded {
|
||||
assetFs = embeddedAssets
|
||||
} else if devMode {
|
||||
// Dev mode: serve frontend source assets (templates, fonts, icons)
|
||||
assetFs = dirFS{Dir: http.Dir("../frontend/public")}
|
||||
} else {
|
||||
// Dev mode: Serve files from internal/web/dist directory
|
||||
assetFs = dirFS{Dir: http.Dir("internal/web/dist")}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,49 +55,79 @@ func unixModeToFileMode(u uint32) os.FileMode {
|
||||
return m
|
||||
}
|
||||
|
||||
// MoveFile moves a file from src to dst.
|
||||
// By default, the rename system call is used. If src and dst point to different volumes,
|
||||
// the file copy is used as a fallback.
|
||||
func MoveFile(src, dst string) error {
|
||||
err := os.Rename(src, dst)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fallback
|
||||
err = CopyFile(src, dst)
|
||||
// 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 {
|
||||
logger.Errorf("CopyFile failed %v", err)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
err = os.RemoveAll(src)
|
||||
if err != nil {
|
||||
logger.Errorf("os.Remove failed %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return os.Remove(dest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveFile moves a file from src to dst.
|
||||
// By default, the rename system call is used. If src and dst are on different volumes,
|
||||
// CopyFile is used as a fallback and the source is removed synchronously on success.
|
||||
func MoveFile(src, dst string) error {
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
if !isCrossDeviceRenameError(err) {
|
||||
return err
|
||||
}
|
||||
if err := CopyFile(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preserveFileMode(path string, mode os.FileMode) error {
|
||||
return os.Chmod(path, mode.Perm())
|
||||
}
|
||||
|
||||
// CopyFile copies a file or directory from source to dest and returns an error if any.
|
||||
func CopyFile(source, dest string) error {
|
||||
// Check if the source exists and whether it's a file or directory.
|
||||
info, err := os.Stat(source)
|
||||
info, err := os.Lstat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return copySymlink(source, dest)
|
||||
}
|
||||
if info.IsDir() {
|
||||
// If the source is a directory, copy it recursively.
|
||||
return copyDirectory(source, dest)
|
||||
}
|
||||
|
||||
// If the source is a file, copy the file.
|
||||
return copySingleFile(source, dest)
|
||||
}
|
||||
|
||||
func copySymlink(source, dest string) error {
|
||||
target, err := os.Readlink(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destDir := filepath.Dir(dest)
|
||||
if err = os.MkdirAll(destDir, EffectiveDirPerm()); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpDir, err := os.MkdirTemp(destDir, ".fb-link-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
tmpPath := filepath.Join(tmpDir, "link")
|
||||
if err := os.Symlink(target, tmpPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, dest)
|
||||
}
|
||||
|
||||
// copySingleFile handles copying a single file.
|
||||
func copySingleFile(source, dest string) error {
|
||||
// Get source file info to preserve permissions
|
||||
@@ -120,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 {
|
||||
@@ -161,7 +195,7 @@ func copyFileContents(dst io.Writer, src io.Reader) error {
|
||||
|
||||
// copyDirectory handles copying directories recursively.
|
||||
func copyDirectory(source, dest string) error {
|
||||
srcInfo, err := os.Stat(source)
|
||||
srcInfo, err := os.Lstat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -170,6 +204,9 @@ func copyDirectory(source, dest string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = preserveFileMode(dest, srcInfo.Mode()); err != nil {
|
||||
logger.Debugf("Could not set directory permissions for %s: %v", dest, err)
|
||||
}
|
||||
|
||||
// Read the contents of the source directory.
|
||||
entries, err := os.ReadDir(source)
|
||||
@@ -182,6 +219,17 @@ func copyDirectory(source, dest string) error {
|
||||
srcPath := filepath.Join(source, entry.Name())
|
||||
destPath := filepath.Join(dest, entry.Name())
|
||||
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
linkTarget := ""
|
||||
linkTarget, err = os.Readlink(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Symlink(linkTarget, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if entry.IsDir() {
|
||||
// Recursively copy subdirectories.
|
||||
err = copyDirectory(srcPath, destPath)
|
||||
|
||||
@@ -139,6 +139,291 @@ func TestCopyFilePreservesModTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// moveViaCrossDeviceFallback mirrors MoveFile's EXDEV fallback (CopyFile + RemoveAll).
|
||||
func moveViaCrossDeviceFallback(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
if err := CopyFile(src, dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.RemoveAll(src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFileDoesNotFallbackOnNonCrossDeviceError(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
src := filepath.Join(srcDir, "file.txt")
|
||||
if err := os.WriteFile(src, []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(srcDir, "existing-dir")
|
||||
if err := os.Mkdir(dst, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := MoveFile(src, dst)
|
||||
if err == nil {
|
||||
t.Fatal("expected rename error")
|
||||
}
|
||||
if _, statErr := os.Stat(src); statErr != nil {
|
||||
t.Fatalf("source file should remain after rename failure: %v", statErr)
|
||||
}
|
||||
entries, err := os.ReadDir(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("destination directory should be unchanged, got %d entries", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFileCrossDevicePreservesFilePermissions(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
src := filepath.Join(srcDir, "secret.txt")
|
||||
if err := os.WriteFile(src, []byte("data"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(dstDir, "secret.txt")
|
||||
|
||||
moveViaCrossDeviceFallback(t, src, dst)
|
||||
got, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Mode().Perm() != 0o640 {
|
||||
t.Fatalf("dst perm = %#o, want 0640", got.Mode().Perm())
|
||||
}
|
||||
if _, err := os.Stat(src); !os.IsNotExist(err) {
|
||||
t.Fatalf("src should be removed, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFileCrossDeviceMovesDirectory(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
dstParent := t.TempDir()
|
||||
nested := filepath.Join(srcDir, "nested.txt")
|
||||
if err := os.WriteFile(nested, []byte("nested"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(srcDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(dstParent, "moved-dir")
|
||||
|
||||
moveViaCrossDeviceFallback(t, srcDir, dst)
|
||||
movedNested := filepath.Join(dst, "nested.txt")
|
||||
if _, err := os.Stat(movedNested); err != nil {
|
||||
t.Fatalf("expected nested file at destination: %v", err)
|
||||
}
|
||||
got, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Mode().Perm() != 0o755 {
|
||||
t.Fatalf("dst dir perm = %#o, want 0755", got.Mode().Perm())
|
||||
}
|
||||
if _, err := os.Stat(srcDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("source directory should be removed, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFileCrossDevicePreservesSymlinkToFile(t *testing.T) {
|
||||
targetDir := t.TempDir()
|
||||
target := filepath.Join(targetDir, "target.txt")
|
||||
if err := os.WriteFile(target, []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
link := filepath.Join(srcDir, "link")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(dstDir, "link")
|
||||
|
||||
moveViaCrossDeviceFallback(t, link, dst)
|
||||
gotTarget, err := os.Readlink(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotTarget != target {
|
||||
t.Fatalf("link target = %q, want %q", gotTarget, target)
|
||||
}
|
||||
info, err := os.Lstat(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatal("expected destination to remain a symlink")
|
||||
}
|
||||
if _, err := os.Stat(link); !os.IsNotExist(err) {
|
||||
t.Fatalf("source symlink should be removed, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFileCrossDevicePreservesSymlinkToDirectory(t *testing.T) {
|
||||
targetDir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(targetDir, "inside"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
link := filepath.Join(srcDir, "dirlink")
|
||||
if err := os.Symlink(targetDir, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(dstDir, "dirlink")
|
||||
|
||||
moveViaCrossDeviceFallback(t, link, dst)
|
||||
gotTarget, err := os.Readlink(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotTarget != targetDir {
|
||||
t.Fatalf("link target = %q, want %q", gotTarget, targetDir)
|
||||
}
|
||||
info, err := os.Lstat(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatal("expected destination to remain a symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFileCrossDeviceMovesDirectoryWithNestedSymlink(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "target.txt")
|
||||
if err := os.WriteFile(target, []byte("linked"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srcDir := t.TempDir()
|
||||
if err := os.Symlink(target, filepath.Join(srcDir, "nested-link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dstParent := t.TempDir()
|
||||
dst := filepath.Join(dstParent, "moved-dir")
|
||||
|
||||
moveViaCrossDeviceFallback(t, srcDir, dst)
|
||||
movedLink := filepath.Join(dst, "nested-link")
|
||||
gotTarget, err := os.Readlink(movedLink)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotTarget != target {
|
||||
t.Fatalf("nested link target = %q, want %q", gotTarget, target)
|
||||
}
|
||||
info, err := os.Lstat(movedLink)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatal("expected nested entry to remain a symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopySymlinkReplacesExistingDestination(t *testing.T) {
|
||||
target := filepath.Join(t.TempDir(), "target.txt")
|
||||
if err := os.WriteFile(target, []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workDir := t.TempDir()
|
||||
src := filepath.Join(workDir, "link")
|
||||
if err := os.Symlink(target, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(workDir, "existing")
|
||||
if err := os.WriteFile(dst, []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := copySymlink(src, dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotTarget, err := os.Readlink(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotTarget != target {
|
||||
t.Fatalf("link target = %q, want %q", gotTarget, target)
|
||||
}
|
||||
info, err := os.Lstat(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatal("expected destination to be a symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFile_ReplacesDestinationSymlinkWithoutFollowingTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outside := filepath.Join(filepath.Dir(dir), "outside-victim.txt")
|
||||
if err := os.WriteFile(outside, []byte("keep me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(dir, "link")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
if err := os.WriteFile(src, []byte("copied"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := CopyFile(src, link); err != nil {
|
||||
t.Fatalf("CopyFile: %v", err)
|
||||
}
|
||||
info, err := os.Lstat(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
t.Fatal("destination should be a regular file replacing the symlink entry")
|
||||
}
|
||||
got, err := os.ReadFile(outside)
|
||||
if err != nil || string(got) != "keep me" {
|
||||
t.Fatalf("copy followed the symlink target: %v %q", err, got)
|
||||
}
|
||||
if body, err := os.ReadFile(link); err != nil || string(body) != "copied" {
|
||||
t.Fatalf("link path content = %q, err=%v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFile_ReplacesDestinationSymlinkWithoutFollowingTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outside := filepath.Join(filepath.Dir(dir), "outside-victim.txt")
|
||||
if err := os.WriteFile(outside, []byte("keep me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(dir, "link")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
if err := os.WriteFile(src, []byte("moved"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := MoveFile(src, link); err != nil {
|
||||
t.Fatalf("MoveFile: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(src); !os.IsNotExist(err) {
|
||||
t.Fatal("source should be gone after move")
|
||||
}
|
||||
info, err := os.Lstat(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
t.Fatal("destination should be a regular file replacing the symlink entry")
|
||||
}
|
||||
got, err := os.ReadFile(outside)
|
||||
if err != nil || string(got) != "keep me" {
|
||||
t.Fatalf("move followed the symlink target: %v %q", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileContentsFallsBackAfterCopyFileRangeBadFileDescriptor(t *testing.T) {
|
||||
const content = "complete file contents"
|
||||
src := struct{ io.Reader }{bytes.NewBufferString(content)}
|
||||
|
||||
@@ -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/")
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func isCrossDeviceRenameError(err error) bool {
|
||||
if errors.Is(err, syscall.EXDEV) {
|
||||
return true
|
||||
}
|
||||
var linkErr *os.LinkError
|
||||
return errors.As(err, &linkErr) && errors.Is(linkErr.Err, syscall.EXDEV)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build windows
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func isCrossDeviceRenameError(err error) bool {
|
||||
const errNotSameDevice = syscall.Errno(17)
|
||||
if errors.Is(err, errNotSameDevice) {
|
||||
return true
|
||||
}
|
||||
var linkErr *os.LinkError
|
||||
return errors.As(err, &linkErr) && errors.Is(linkErr.Err, errNotSameDevice)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(testutil.Main(m))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(testutil.Main(m))
|
||||
}
|
||||
@@ -15,10 +15,7 @@ type NoAuth struct{}
|
||||
|
||||
// AuthenticateNoAuth authenticates as the configured admin user with no credentials required.
|
||||
func AuthenticateNoAuth(r *http.Request, user *users.Storage) (*users.User, error) {
|
||||
admin := settings.Config.Auth.AdminUsername
|
||||
if admin == "" {
|
||||
admin = "admin"
|
||||
}
|
||||
admin := settings.PasswordAdminUsername()
|
||||
id, err := users.ResolveUsernameToID(admin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
@@ -38,3 +40,32 @@ func AuthenticateProxy(r *http.Request, usr *users.Storage, headerName string) (
|
||||
func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||
return AuthenticateProxy(r, usr, a.Header)
|
||||
}
|
||||
|
||||
// ExtractGroupsFromHeader reads group/role values from a proxy header named by groupsClaim.
|
||||
// Returns present=false when the header name is empty or the header is missing/blank.
|
||||
func ExtractGroupsFromHeader(r *http.Request, headerName string) ([]string, bool) {
|
||||
if headerName == "" {
|
||||
return nil, false
|
||||
}
|
||||
headerVal := strings.TrimSpace(r.Header.Get(headerName))
|
||||
if headerVal == "" {
|
||||
return nil, false
|
||||
}
|
||||
if strings.HasPrefix(headerVal, "[") {
|
||||
var groups []string
|
||||
if err := json.Unmarshal([]byte(headerVal), &groups); err == nil {
|
||||
return groups, true
|
||||
}
|
||||
}
|
||||
groups := ExtractGroupsFromClaims(map[string]interface{}{headerName: headerVal}, headerName)
|
||||
if len(groups) == 1 && strings.Contains(groups[0], ",") {
|
||||
parts := strings.Split(groups[0], ",")
|
||||
groups = make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
groups = append(groups, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups, true
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
|
||||
)
|
||||
|
||||
func TestExtractGroupsFromHeader_SingleValue(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-Cosmos-Role", "2")
|
||||
|
||||
groups, present := auth.ExtractGroupsFromHeader(req, "x-cosmos-role")
|
||||
if !present {
|
||||
t.Fatal("expected groups to be present")
|
||||
}
|
||||
if len(groups) != 1 || groups[0] != "2" {
|
||||
t.Fatalf("groups = %#v, want [\"2\"]", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromHeader_CommaSeparated(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-Groups", "1, 2")
|
||||
|
||||
groups, present := auth.ExtractGroupsFromHeader(req, "X-Groups")
|
||||
if !present {
|
||||
t.Fatal("expected groups to be present")
|
||||
}
|
||||
if len(groups) != 2 || groups[0] != "1" || groups[1] != "2" {
|
||||
t.Fatalf("groups = %#v, want [\"1\", \"2\"]", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromHeader_JSONArray(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-Groups", `["admin","users"]`)
|
||||
|
||||
groups, present := auth.ExtractGroupsFromHeader(req, "X-Groups")
|
||||
if !present {
|
||||
t.Fatal("expected groups to be present")
|
||||
}
|
||||
if len(groups) != 2 || groups[0] != "admin" || groups[1] != "users" {
|
||||
t.Fatalf("groups = %#v, want [\"admin\", \"users\"]", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromHeader_Missing(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
groups, present := auth.ExtractGroupsFromHeader(req, "X-Groups")
|
||||
if present {
|
||||
t.Fatalf("expected groups to be absent, got %#v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromHeader_EmptyHeaderName(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-Groups", "admin")
|
||||
|
||||
groups, present := auth.ExtractGroupsFromHeader(req, "")
|
||||
if present {
|
||||
t.Fatalf("expected groups to be absent, got %#v", groups)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
package access_test
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -1851,3 +1852,200 @@ func TestAddUserToGroup_WriteThrough(t *testing.T) {
|
||||
t.Fatal("bob should be removed from editors in SQL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGroupMembers_ReplacesMembership(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, _, sqlStore := createTestStorageWithSQL(t)
|
||||
|
||||
if err := s.SetGroupMembers("editors", []string{"alice", "bob", ""}); err != nil {
|
||||
t.Fatalf("SetGroupMembers: %v", err)
|
||||
}
|
||||
if err := s.SetGroupMembers("editors", []string{"bob", "carol"}); err != nil {
|
||||
t.Fatalf("SetGroupMembers replace: %v", err)
|
||||
}
|
||||
got := s.GetGroupMembers()["editors"]
|
||||
if len(got) != 2 || got[0] != "bob" || got[1] != "carol" {
|
||||
t.Fatalf("unexpected members: %v", got)
|
||||
}
|
||||
sqlGroups, err := sqlStore.GetAllGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllGroups: %v", err)
|
||||
}
|
||||
if _, ok := sqlGroups["editors"]["alice"]; ok {
|
||||
t.Fatal("alice should have been removed in SQL")
|
||||
}
|
||||
if _, ok := sqlGroups["editors"]["carol"]; !ok {
|
||||
t.Fatal("carol should be persisted in SQL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGroup_RemovesGroupAndRules(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, _, sqlStore := createTestStorageWithSQL(t)
|
||||
|
||||
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
|
||||
t.Fatalf("SetGroupMembers: %v", err)
|
||||
}
|
||||
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
|
||||
t.Fatalf("AllowGroup: %v", err)
|
||||
}
|
||||
if err := s.DeleteGroup("acme"); err != nil {
|
||||
t.Fatalf("DeleteGroup: %v", err)
|
||||
}
|
||||
if _, ok := s.GetGroupMembers()["acme"]; ok {
|
||||
t.Fatal("acme should be gone from memory")
|
||||
}
|
||||
sqlGroups, err := sqlStore.GetAllGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllGroups: %v", err)
|
||||
}
|
||||
if _, ok := sqlGroups["acme"]; ok {
|
||||
t.Fatal("acme should be gone from SQL")
|
||||
}
|
||||
if rules := s.GetRulesForGroup("mnt/storage", "acme"); len(rules) != 0 {
|
||||
t.Fatalf("expected no rules for deleted group, got %v", rules)
|
||||
}
|
||||
// Deleting a missing group is a no-op.
|
||||
if err := s.DeleteGroup("acme"); err != nil {
|
||||
t.Fatalf("DeleteGroup missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// failingGroupDeletePersister wraps a real store but fails the transactional group delete.
|
||||
type failingGroupDeletePersister struct {
|
||||
access.SQLPersister
|
||||
}
|
||||
|
||||
func (failingGroupDeletePersister) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
|
||||
return stderrors.New("simulated sql failure")
|
||||
}
|
||||
|
||||
func TestDeleteGroup_FailureLeavesGroupAndRulesIntact(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, _, sqlStore := createTestStorageWithSQL(t)
|
||||
|
||||
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
|
||||
t.Fatalf("SetGroupMembers: %v", err)
|
||||
}
|
||||
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
|
||||
t.Fatalf("AllowGroup: %v", err)
|
||||
}
|
||||
if err := s.DenyGroup("mnt/storage", idxPath("/private"), "acme"); err != nil {
|
||||
t.Fatalf("DenyGroup: %v", err)
|
||||
}
|
||||
rulesBefore := len(s.GetRulesForGroup("mnt/storage", "acme"))
|
||||
if rulesBefore != 2 {
|
||||
t.Fatalf("expected 2 rules before delete, got %d", rulesBefore)
|
||||
}
|
||||
|
||||
s.SetSQLStore(failingGroupDeletePersister{sqlStore})
|
||||
if err := s.DeleteGroup("acme"); err == nil {
|
||||
t.Fatal("expected DeleteGroup to return the SQL error")
|
||||
}
|
||||
|
||||
if got := s.GetGroupMembers()["acme"]; len(got) != 1 || got[0] != "alice" {
|
||||
t.Fatalf("group should be restored after a failed delete, got %v", got)
|
||||
}
|
||||
if got := len(s.GetRulesForGroup("mnt/storage", "acme")); got != rulesBefore {
|
||||
t.Fatalf("rules should be restored after a failed delete: want %d, got %d", rulesBefore, got)
|
||||
}
|
||||
// SQL was never changed.
|
||||
sqlGroups, err := sqlStore.GetAllGroups()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := sqlGroups["acme"]; !ok {
|
||||
t.Fatal("group row should still be in SQL")
|
||||
}
|
||||
sqlRules, err := sqlStore.GetAllAccessRules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sqlRules["mnt/storage"]) != 2 {
|
||||
t.Fatalf("rule rows should still be in SQL, got %v", sqlRules["mnt/storage"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGroup_RemovesGroupAndRuleRowsFromSQL(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, _, sqlStore := createTestStorageWithSQL(t)
|
||||
|
||||
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
|
||||
t.Fatalf("SetGroupMembers: %v", err)
|
||||
}
|
||||
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
|
||||
t.Fatalf("AllowGroup: %v", err)
|
||||
}
|
||||
if err := s.AllowUser("mnt/storage", idxPath("/tenant"), "alice"); err != nil {
|
||||
t.Fatalf("AllowUser: %v", err)
|
||||
}
|
||||
if err := s.DeleteGroup("acme"); err != nil {
|
||||
t.Fatalf("DeleteGroup: %v", err)
|
||||
}
|
||||
|
||||
sqlGroups, err := sqlStore.GetAllGroups()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := sqlGroups["acme"]; ok {
|
||||
t.Fatal("group row should be gone from SQL")
|
||||
}
|
||||
sqlRules, err := sqlStore.GetAllAccessRules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range sqlRules["mnt/storage"] {
|
||||
if _, has := rule.Allow.Groups["acme"]; has {
|
||||
t.Fatal("no stored rule should still reference the deleted group")
|
||||
}
|
||||
}
|
||||
// The rule that also allowed a user must survive with only the group removed.
|
||||
if len(sqlRules["mnt/storage"]) != 1 {
|
||||
t.Fatalf("expected the user rule to remain, got %v", sqlRules["mnt/storage"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGroup_KeepsDenyAllRule(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, _, sqlStore := createTestStorageWithSQL(t)
|
||||
|
||||
// "Deny everyone except group acme": a DenyAll rule whose only allow entry is the group.
|
||||
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
|
||||
t.Fatalf("SetGroupMembers: %v", err)
|
||||
}
|
||||
if err := s.DenyAll("mnt/storage", idxPath("/tenant")); err != nil {
|
||||
t.Fatalf("DenyAll: %v", err)
|
||||
}
|
||||
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
|
||||
t.Fatalf("AllowGroup: %v", err)
|
||||
}
|
||||
if !s.Permitted("mnt/storage", idxPath("/tenant"), "alice") {
|
||||
t.Fatal("alice should be permitted through the group before it is deleted")
|
||||
}
|
||||
|
||||
if err := s.DeleteGroup("acme"); err != nil {
|
||||
t.Fatalf("DeleteGroup: %v", err)
|
||||
}
|
||||
|
||||
// Deleting the group must not remove the deny-all rule and open the path to everyone.
|
||||
if s.Permitted("mnt/storage", idxPath("/tenant"), "alice") || s.Permitted("mnt/storage", idxPath("/tenant"), "bob") {
|
||||
t.Fatal("path must stay denied for everyone after the allowed group is deleted")
|
||||
}
|
||||
rules := s.GetRulesForGroup("mnt/storage", "acme")
|
||||
if len(rules) != 0 {
|
||||
t.Fatalf("no rule should reference the deleted group, got %v", rules)
|
||||
}
|
||||
sqlRules, err := sqlStore.GetAllAccessRules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored := 0
|
||||
for _, r := range sqlRules["mnt/storage"] {
|
||||
if r.DenyAll {
|
||||
stored++
|
||||
}
|
||||
}
|
||||
if stored != 1 {
|
||||
t.Fatalf("expected the deny-all rule to remain stored in SQL, found %d", stored)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package access_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
type stubGroupSQL struct {
|
||||
saveErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func (s *stubGroupSQL) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
|
||||
func (s *stubGroupSQL) DeleteAccessRule(string, string) error { return nil }
|
||||
func (s *stubGroupSQL) AccessRulesLowercaseMigrated() (bool, error) { return false, nil }
|
||||
func (s *stubGroupSQL) SetAccessRulesLowercaseMigrated() error { return nil }
|
||||
func (s *stubGroupSQL) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubGroupSQL) SaveGroup(string, access.StringSet) error {
|
||||
return s.saveErr
|
||||
}
|
||||
func (s *stubGroupSQL) DeleteGroup(string) error { return s.deleteErr }
|
||||
func (s *stubGroupSQL) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
|
||||
return s.deleteErr
|
||||
}
|
||||
func (s *stubGroupSQL) SaveRevokedToken(string, int64) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubGroupSQL) PersistImmediateTokenRevocation(string) error { return nil }
|
||||
func (s *stubGroupSQL) PersistTokenRetirement(string, int64, []string) error { return nil }
|
||||
func (s *stubGroupSQL) DeleteRevokedToken(string) error { return nil }
|
||||
func (s *stubGroupSQL) SaveHashedToken(string, uint64, bool, int64) error { return nil }
|
||||
func (s *stubGroupSQL) DeleteHashedToken(string) error { return nil }
|
||||
func (s *stubGroupSQL) DeleteHashedTokensByUserID(uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingGroupSQL struct {
|
||||
groups map[string]access.StringSet
|
||||
saveCalls int
|
||||
failOn int
|
||||
}
|
||||
|
||||
func (s *recordingGroupSQL) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
|
||||
func (s *recordingGroupSQL) DeleteAccessRule(string, string) error { return nil }
|
||||
func (s *recordingGroupSQL) AccessRulesLowercaseMigrated() (bool, error) { return false, nil }
|
||||
func (s *recordingGroupSQL) SetAccessRulesLowercaseMigrated() error { return nil }
|
||||
func (s *recordingGroupSQL) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
|
||||
return nil
|
||||
}
|
||||
func (s *recordingGroupSQL) SaveGroup(name string, members access.StringSet) error {
|
||||
if s.groups == nil {
|
||||
s.groups = make(map[string]access.StringSet)
|
||||
}
|
||||
s.saveCalls++
|
||||
if s.failOn > 0 && s.saveCalls == s.failOn {
|
||||
return errors.New("save failed")
|
||||
}
|
||||
s.groups[name] = cloneStringSet(members)
|
||||
return nil
|
||||
}
|
||||
func (s *recordingGroupSQL) DeleteGroup(name string) error {
|
||||
if s.groups == nil {
|
||||
s.groups = make(map[string]access.StringSet)
|
||||
}
|
||||
delete(s.groups, name)
|
||||
return nil
|
||||
}
|
||||
func (s *recordingGroupSQL) DeleteGroupWithRules(name string, _ []access.RuleUpsert, _ []access.RuleKey) error {
|
||||
return s.DeleteGroup(name)
|
||||
}
|
||||
func (s *recordingGroupSQL) SaveRevokedToken(string, int64) error { return nil }
|
||||
func (s *recordingGroupSQL) PersistImmediateTokenRevocation(string) error { return nil }
|
||||
func (s *recordingGroupSQL) PersistTokenRetirement(string, int64, []string) error { return nil }
|
||||
func (s *recordingGroupSQL) DeleteRevokedToken(string) error { return nil }
|
||||
func (s *recordingGroupSQL) SaveHashedToken(string, uint64, bool, int64) error { return nil }
|
||||
func (s *recordingGroupSQL) DeleteHashedToken(string) error { return nil }
|
||||
func (s *recordingGroupSQL) DeleteHashedTokensByUserID(uint64) error { return nil }
|
||||
|
||||
func cloneStringSet(src access.StringSet) access.StringSet {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make(access.StringSet, len(src))
|
||||
maps.Copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func TestAddUserToGroup_RollsBackOnSQLFailure(t *testing.T) {
|
||||
userStore := users.NewStorage(nil)
|
||||
store := access.NewStorage(userStore)
|
||||
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
|
||||
|
||||
err := store.AddUserToGroup("editors", "alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected SQL save error")
|
||||
}
|
||||
groups := store.GetUserGroups("alice")
|
||||
if len(groups) != 0 {
|
||||
t.Fatalf("expected in-memory rollback, alice groups = %#v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserFromGroup_RollsBackOnSQLFailure(t *testing.T) {
|
||||
userStore := users.NewStorage(nil)
|
||||
store := access.NewStorage(userStore)
|
||||
if err := store.AddUserToGroup("editors", "alice"); err != nil {
|
||||
t.Fatalf("seed group: %v", err)
|
||||
}
|
||||
|
||||
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
|
||||
err := store.RemoveUserFromGroup("editors", "alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected SQL save error")
|
||||
}
|
||||
groups := store.GetUserGroups("alice")
|
||||
if len(groups) != 1 || groups[0] != "editors" {
|
||||
t.Fatalf("expected in-memory rollback, alice groups = %#v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowGroup_ReturnsSQLSaveError(t *testing.T) {
|
||||
setupTestSources()
|
||||
store := access.NewStorage(users.NewStorage(nil))
|
||||
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
|
||||
|
||||
err := store.AllowGroup("mnt/storage", idxPath("/tenant"), "acme")
|
||||
if err == nil {
|
||||
t.Fatal("expected SQL save error from AllowGroup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureGroupExistsNL_RollsBackOnSQLFailure(t *testing.T) {
|
||||
setupTestSources()
|
||||
store := access.NewStorage(users.NewStorage(nil))
|
||||
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
|
||||
|
||||
err := store.AllowGroup("mnt/storage", idxPath("/tenant"), "newgroup")
|
||||
if err == nil {
|
||||
t.Fatal("expected SQL save error")
|
||||
}
|
||||
if groups := store.GetAllGroups(); len(groups) != 0 {
|
||||
t.Fatalf("expected no in-memory group after failed ensure, got %#v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncUserGroups_RollsBackOnPartialPersistFailure(t *testing.T) {
|
||||
userStore := users.NewStorage(nil)
|
||||
store := access.NewStorage(userStore)
|
||||
store.Groups["groupA"] = access.StringSet{"alice": {}}
|
||||
rec := &recordingGroupSQL{
|
||||
failOn: 2,
|
||||
groups: map[string]access.StringSet{
|
||||
"groupA": {"alice": {}},
|
||||
},
|
||||
}
|
||||
store.SetSQLStore(rec)
|
||||
|
||||
err := store.SyncUserGroups("alice", []string{"groupB"})
|
||||
if err == nil {
|
||||
t.Fatal("expected partial persist failure")
|
||||
}
|
||||
groups := store.GetUserGroups("alice")
|
||||
if len(groups) != 1 || groups[0] != "groupA" {
|
||||
t.Fatalf("expected in-memory rollback to groupA only, got %#v", groups)
|
||||
}
|
||||
if _, ok := store.Groups["groupB"]; ok {
|
||||
t.Fatal("groupB should not remain in memory after rollback")
|
||||
}
|
||||
if rec.saveCalls != 3 {
|
||||
t.Fatalf("saveCalls = %d, want 3 (groupA persist, groupB fail, groupA restore)", rec.saveCalls)
|
||||
}
|
||||
if _, ok := rec.groups["groupA"]["alice"]; !ok {
|
||||
t.Fatalf("expected SQL rollback for groupA, got %#v", rec.groups["groupA"])
|
||||
}
|
||||
if _, ok := rec.groups["groupB"]; ok {
|
||||
t.Fatalf("expected SQL groupB to be absent after rollback, got %#v", rec.groups["groupB"])
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(testutil.Main(m))
|
||||
}
|
||||
@@ -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,20 +14,26 @@ 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) SaveRevokedToken(string, int64) error { return nil }
|
||||
func (failingRevokePersister) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
|
||||
return nil
|
||||
}
|
||||
func (failingRevokePersister) SaveRevokedToken(string, int64) error { return nil }
|
||||
func (failingRevokePersister) PersistImmediateTokenRevocation(string) error {
|
||||
return errors.New("simulated revocation persistence failure")
|
||||
}
|
||||
func (failingRevokePersister) PersistTokenRetirement(string, int64, []string) error {
|
||||
return nil
|
||||
return errors.New("simulated retirement persistence failure")
|
||||
}
|
||||
func (failingRevokePersister) DeleteRevokedToken(string) error { return nil }
|
||||
func (failingRevokePersister) SaveHashedToken(string, uint64, bool, int64) error {
|
||||
return nil
|
||||
}
|
||||
func (failingRevokePersister) DeleteHashedToken(string) error { return nil }
|
||||
func (failingRevokePersister) DeleteRevokedToken(string) error { return nil }
|
||||
func (failingRevokePersister) SaveHashedToken(string, uint64, bool, int64) error { return nil }
|
||||
func (failingRevokePersister) DeleteHashedToken(string) error { return nil }
|
||||
func (failingRevokePersister) DeleteHashedTokensByUserID(uint64) error { return nil }
|
||||
|
||||
func TestSessionAndApiTokenMetadata(t *testing.T) {
|
||||
@@ -74,6 +80,24 @@ func TestRevokeTokenRollsBackMemoryOnPersistenceFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetireTokenRollsBackMemoryOnPersistenceFailure(t *testing.T) {
|
||||
store, _ := createTestStorage(t)
|
||||
store.SetSQLStore(failingRevokePersister{})
|
||||
|
||||
if err := store.AddSessionToken("tok", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RetireToken("tok"); err == nil {
|
||||
t.Fatal("expected RetireToken to propagate persistence failure")
|
||||
}
|
||||
if store.IsTokenRevoked("tok") {
|
||||
t.Fatal("failed retirement must not leave in-memory revoked state")
|
||||
}
|
||||
if _, ok := store.GetHashedTokenInfo("tok"); !ok {
|
||||
t.Fatal("failed retirement must keep owner mapping")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeTokenIsImmediate(t *testing.T) {
|
||||
store, _ := createTestStorage(t)
|
||||
|
||||
|
||||
@@ -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,31 +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"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -41,6 +45,7 @@ var AllEventTypes = []EventType{
|
||||
EventCopy,
|
||||
EventRename,
|
||||
EventUpload,
|
||||
EventFileModify,
|
||||
EventDelete,
|
||||
EventBulkDelete,
|
||||
EventArchive,
|
||||
@@ -54,6 +59,9 @@ var AllEventTypes = []EventType{
|
||||
EventAccessUpdate,
|
||||
EventAccessCreate,
|
||||
EventAccessDelete,
|
||||
EventQuotaCreate,
|
||||
EventQuotaUpdate,
|
||||
EventQuotaDelete,
|
||||
EventLogin,
|
||||
EventLogout,
|
||||
EventSignup,
|
||||
@@ -71,6 +79,7 @@ var FileEventTypes = []EventType{
|
||||
EventCopy,
|
||||
EventRename,
|
||||
EventUpload,
|
||||
EventFileModify,
|
||||
EventDelete,
|
||||
EventBulkDelete,
|
||||
EventArchive,
|
||||
@@ -84,6 +93,13 @@ var AccessEventTypes = []EventType{
|
||||
EventAccessDelete,
|
||||
}
|
||||
|
||||
// QuotaEventTypes are folder quota mutations (scope=quotas).
|
||||
var QuotaEventTypes = []EventType{
|
||||
EventQuotaCreate,
|
||||
EventQuotaUpdate,
|
||||
EventQuotaDelete,
|
||||
}
|
||||
|
||||
// ShareEventTypes are share lifecycle events (scope=shares). Share downloads are
|
||||
// EventDownload rows with a non-empty details.shareHash.
|
||||
var ShareEventTypes = []EventType{
|
||||
@@ -108,13 +124,15 @@ func ResolveScopeEventTypes(scope string, explicit []EventType) ([]EventType, er
|
||||
allowed = FileEventTypes
|
||||
case "access":
|
||||
allowed = AccessEventTypes
|
||||
case "quotas":
|
||||
allowed = QuotaEventTypes
|
||||
case "shares":
|
||||
if len(explicit) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
allowed = ShareScopeEventTypes
|
||||
default:
|
||||
return nil, fmt.Errorf("scope must be all, files, access, or shares")
|
||||
return nil, fmt.Errorf("scope must be all, files, access, quotas, or shares")
|
||||
}
|
||||
if len(explicit) == 0 {
|
||||
return allowed, nil
|
||||
@@ -140,10 +158,11 @@ 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,
|
||||
EventQuotaCreate, EventQuotaUpdate, EventQuotaDelete,
|
||||
EventLogin, EventLogout, EventSignup,
|
||||
EventPasskeyRegister, EventPasskeyDelete,
|
||||
EventTokenCreate, EventTokenDelete,
|
||||
|
||||
@@ -44,6 +44,18 @@ func TestResolveScopeEventTypes(t *testing.T) {
|
||||
t.Fatalf("access default missing %q in %v", et, got)
|
||||
}
|
||||
}
|
||||
got, err = ResolveScopeEventTypes("quotas", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != len(QuotaEventTypes) {
|
||||
t.Fatalf("quotas default: got %d types, want %d", len(got), len(QuotaEventTypes))
|
||||
}
|
||||
for _, et := range QuotaEventTypes {
|
||||
if !containsEventType(got, et) {
|
||||
t.Fatalf("quotas default missing %q in %v", et, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsEventType(list []EventType, target EventType) bool {
|
||||
|
||||
@@ -21,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"`
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
)
|
||||
|
||||
// MeasurementStatusReady means enforcement uses the configured meter.
|
||||
const MeasurementStatusReady = "ready"
|
||||
|
||||
// MeasurementStatusAccountedFallback means index meter configured but accounted counter used.
|
||||
const MeasurementStatusAccountedFallback = "accounted_fallback"
|
||||
|
||||
// IsIndexMeter reports whether meter counts usage from the search index.
|
||||
func IsIndexMeter(meter string) bool {
|
||||
return meter == MeterIndexSize || meter == MeterIndexScope
|
||||
}
|
||||
|
||||
// IndexingEnabled reports whether folder sizes are available from the index on this source.
|
||||
func IndexingEnabled(sourceName string) bool {
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
return false
|
||||
}
|
||||
return !idx.Config.ResolvedRules.IndexingDisabled
|
||||
}
|
||||
|
||||
// IndexSizeAvailable reports whether GetFolderSize succeeds for the quota root path.
|
||||
func IndexSizeAvailable(sourceName, indexPath string) bool {
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil || idx.Config.ResolvedRules.IndexingDisabled {
|
||||
return false
|
||||
}
|
||||
path := normalizeMeterPath(indexPath)
|
||||
_, ok := idx.GetFolderSizeForIndexPath(path)
|
||||
return ok
|
||||
}
|
||||
|
||||
// EffectiveMeter resolves runtime meter: accounted when indexing off, configured index when size known, else accounted fallback.
|
||||
func EffectiveMeter(configuredMeter, sourceName, quotaRootPath string) string {
|
||||
if configuredMeter == MeterAccounted || !IsIndexMeter(configuredMeter) {
|
||||
return MeterAccounted
|
||||
}
|
||||
if !IndexingEnabled(sourceName) {
|
||||
return MeterAccounted
|
||||
}
|
||||
if IndexSizeAvailable(sourceName, quotaRootPath) {
|
||||
return configuredMeter
|
||||
}
|
||||
return MeterAccounted
|
||||
}
|
||||
|
||||
// MeasurementStatus compares configured vs effective meter for API snapshots.
|
||||
func MeasurementStatus(configuredMeter, effectiveMeter string) string {
|
||||
if IsIndexMeter(configuredMeter) && effectiveMeter == MeterAccounted {
|
||||
return MeasurementStatusAccountedFallback
|
||||
}
|
||||
return MeasurementStatusReady
|
||||
}
|
||||
|
||||
// ValidateConfiguredMeter rejects index meters when indexing is disabled on the source.
|
||||
func ValidateConfiguredMeter(sourceName, meter string) error {
|
||||
meter = strings.TrimSpace(meter)
|
||||
if meter == "" {
|
||||
return nil
|
||||
}
|
||||
if meter == MeterAccounted {
|
||||
return nil
|
||||
}
|
||||
if IsIndexMeter(meter) {
|
||||
if !IndexingEnabled(sourceName) {
|
||||
return fmt.Errorf("index meter not allowed when indexing is disabled on source %q", sourceName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid quota meter %q", meter)
|
||||
}
|
||||
|
||||
func normalizeMeterPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package quota_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func setupMeterTestSource(t *testing.T, name, path string, indexingDisabled bool) {
|
||||
t.Helper()
|
||||
prevSourceMap := settings.Config.Server.SourceMap
|
||||
prevNameToSource := settings.Config.Server.NameToSource
|
||||
t.Cleanup(func() {
|
||||
indexing.ClearTestIndices()
|
||||
settings.Config.Server.SourceMap = prevSourceMap
|
||||
settings.Config.Server.NameToSource = prevNameToSource
|
||||
})
|
||||
indexing.SetTestIndex(name, path)
|
||||
idx := indexing.GetIndex(name)
|
||||
if idx == nil {
|
||||
t.Fatal("expected test index")
|
||||
}
|
||||
idx.Config.ResolvedRules.IndexingDisabled = indexingDisabled
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
path: {Path: path, Name: name},
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
name: settings.Config.Server.SourceMap[path],
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfiguredMeter_IndexingDisabled(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
name := "meter-src"
|
||||
setupMeterTestSource(t, name, root, true)
|
||||
|
||||
if err := quota.ValidateConfiguredMeter(name, quota.MeterIndexSize); err == nil {
|
||||
t.Fatal("expected error for index_size when indexing disabled")
|
||||
}
|
||||
if err := quota.ValidateConfiguredMeter(name, quota.MeterIndexScope); err == nil {
|
||||
t.Fatal("expected error for index_scope when indexing disabled")
|
||||
}
|
||||
if err := quota.ValidateConfiguredMeter(name, quota.MeterAccounted); err != nil {
|
||||
t.Fatalf("accounted should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveMeter_IndexingDisabledForcesAccounted(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
name := "meter-src2"
|
||||
setupMeterTestSource(t, name, root, true)
|
||||
|
||||
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/")
|
||||
if got != quota.MeterAccounted {
|
||||
t.Fatalf("expected accounted, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveMeter_ExplicitAccountedWhenIndexOn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
name := "meter-src3"
|
||||
setupMeterTestSource(t, name, root, false)
|
||||
idx := indexing.GetIndex(name)
|
||||
idx.SetFolderSize("/", 1024)
|
||||
|
||||
got := quota.EffectiveMeter(quota.MeterAccounted, name, "/")
|
||||
if got != quota.MeterAccounted {
|
||||
t.Fatalf("expected accounted, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveMeter_IndexWhenSizeAvailable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
name := "meter-src4"
|
||||
setupMeterTestSource(t, name, root, false)
|
||||
idx := indexing.GetIndex(name)
|
||||
idx.SetFolderSize("/", 2048)
|
||||
|
||||
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/")
|
||||
if got != quota.MeterIndexSize {
|
||||
t.Fatalf("expected index_size, got %q", got)
|
||||
}
|
||||
status := quota.MeasurementStatus(quota.MeterIndexSize, got)
|
||||
if status != quota.MeasurementStatusReady {
|
||||
t.Fatalf("expected ready, got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveMeter_IndexPathTrailingSlash(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
name := "meter-src6"
|
||||
setupMeterTestSource(t, name, root, false)
|
||||
idx := indexing.GetIndex(name)
|
||||
idx.SetFolderSize("/projects/", 4096)
|
||||
|
||||
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/projects")
|
||||
if got != quota.MeterIndexSize {
|
||||
t.Fatalf("expected index_size for path without trailing slash, got %q", got)
|
||||
}
|
||||
}
|
||||
func TestEffectiveMeter_RuntimeFallback(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
name := "meter-src5"
|
||||
setupMeterTestSource(t, name, root, false)
|
||||
|
||||
got := quota.EffectiveMeter(quota.MeterIndexScope, name, "/missing")
|
||||
if got != quota.MeterAccounted {
|
||||
t.Fatalf("expected accounted fallback, got %q", got)
|
||||
}
|
||||
status := quota.MeasurementStatus(quota.MeterIndexScope, got)
|
||||
if status != quota.MeasurementStatusAccountedFallback {
|
||||
t.Fatalf("expected accounted_fallback, got %q", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package quota
|
||||
|
||||
// Meter names for scope and folder quotas.
|
||||
const (
|
||||
MeterIndexScope = "index_scope"
|
||||
MeterIndexSize = "index_size"
|
||||
MeterAccounted = "accounted"
|
||||
)
|
||||
|
||||
// FolderQuota is a path-bound storage cap with configurable meter.
|
||||
type FolderQuota struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"` // backend source path
|
||||
Path string `json:"path"` // index path within source
|
||||
UserID uint64 `json:"userId,omitempty"` // 0 = all users
|
||||
LimitBytes int64 `json:"limitBytes"`
|
||||
Meter string `json:"meter,omitempty"` // index_size | index_scope (indexed size) | accounted (tracked usage)
|
||||
}
|
||||
|
||||
// Counter holds accounted usage for a quota_id.
|
||||
type Counter struct {
|
||||
QuotaID string `json:"quotaId"`
|
||||
UsedBytes int64 `json:"usedBytes"`
|
||||
ReservedBytes int64 `json:"reservedBytes"`
|
||||
}
|
||||
|
||||
// Snapshot is a read model for API and admission checks.
|
||||
type Snapshot struct {
|
||||
QuotaID string `json:"quotaId"`
|
||||
Kind string `json:"quotaKind"` // folder, scope, share
|
||||
LimitBytes int64 `json:"limitBytes"`
|
||||
UsedBytes int64 `json:"usedBytes"`
|
||||
ReservedBytes int64 `json:"reservedBytes"`
|
||||
Meter string `json:"meter,omitempty"` // effective meter (backward compatible)
|
||||
ConfiguredMeter string `json:"configuredMeter,omitempty"`
|
||||
EffectiveMeter string `json:"effectiveMeter,omitempty"`
|
||||
MeasurementStatus string `json:"measurementStatus,omitempty"` // ready, accounted_fallback
|
||||
}
|
||||
|
||||
// ShareQuotaID returns the counter key for a share quota.
|
||||
func ShareQuotaID(hash string) string {
|
||||
return "share:" + hash
|
||||
}
|
||||
@@ -48,6 +48,7 @@ type ShareLimits struct {
|
||||
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles,omitempty"`
|
||||
DownloadsLimit int `json:"downloadsLimit,omitempty"`
|
||||
QuotaLimitBytes int64 `json:"quotaLimitBytes,omitempty"`
|
||||
HideFileExt string `json:"hideFileExt,omitempty"` // show hidden files based on extensions in shares
|
||||
Banner string `json:"banner,omitempty"`
|
||||
SourceName string `json:"source,omitempty"` // source display name for API; backend path is Share.SourcePath
|
||||
@@ -85,8 +86,11 @@ type ShareEditable struct {
|
||||
type ShareFrontend struct {
|
||||
ShareEditable
|
||||
ShareColumns
|
||||
Username string `json:"username,omitempty"`
|
||||
PathExists bool `json:"pathExists,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
PathExists bool `json:"pathExists,omitempty"`
|
||||
QuotaUsedBytes int64 `json:"quotaUsedBytes,omitempty"`
|
||||
QuotaReservedBytes int64 `json:"quotaReservedBytes,omitempty"`
|
||||
QuotaAvailableBytes int64 `json:"quotaAvailableBytes,omitempty"`
|
||||
}
|
||||
|
||||
// SharePostBody is POST /api/share JSON. Plaintext password is hashed to Share.PasswordHash before persist.
|
||||
@@ -98,6 +102,19 @@ type SharePostBody struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// EditableFromShare returns the client-editable subset of a persisted share.
|
||||
// ShareExpiryInput is omitted because expiration is stored in ShareColumns.Expire;
|
||||
// callers merging partial updates must preserve Expire when expires/unit are absent.
|
||||
func EditableFromShare(s *Share) ShareEditable {
|
||||
if s == nil {
|
||||
return ShareEditable{}
|
||||
}
|
||||
return ShareEditable{
|
||||
FrontendShareInfo: s.FrontendShareInfo,
|
||||
ShareLimits: s.ShareLimits,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyPostBodyUpdate copies client-editable fields onto link.
|
||||
// Caller must preserve path, sourcePath, pinnedItems, version, download counters, and secrets.
|
||||
func ApplyPostBodyUpdate(link *Share, req *SharePostBody, expire int64) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,43 @@ func (s *SQLStore) SaveGroup(name string, members access.StringSet) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteGroupWithRules deletes a group and applies access rule row changes in a single
|
||||
// transaction. If any statement fails nothing is written. A group row that is already
|
||||
// absent is not an error, so the call is safe to retry.
|
||||
func (s *SQLStore) DeleteGroupWithRules(name string, save []access.RuleUpsert, remove []access.RuleKey) error {
|
||||
tx, err := s.BeginTx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %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 _, u := range save {
|
||||
ruleDataJSON, err := json.Marshal(u.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 (?, ?, ?)`, u.Source, u.Path, ruleDataJSON); err != nil {
|
||||
return rollback(fmt.Errorf("failed to save access rule: %w", err))
|
||||
}
|
||||
}
|
||||
for _, k := range remove {
|
||||
if _, err := tx.Exec(`DELETE FROM access_rules WHERE source = ? AND path = ?`, k.Source, k.Path); err != nil {
|
||||
return rollback(fmt.Errorf("failed to delete access rule: %w", err))
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM groups WHERE name = ?`, name); err != nil {
|
||||
return rollback(fmt.Errorf("failed to delete group: %w", err))
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit group delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteGroup deletes a group by name
|
||||
func (s *SQLStore) DeleteGroup(name string) error {
|
||||
query := `DELETE FROM groups WHERE name = ?`
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
)
|
||||
|
||||
// newGroupTestStore opens a fresh SQLStore in a temp dir.
|
||||
func newGroupTestStore(t *testing.T) *SQLStore {
|
||||
t.Helper()
|
||||
store, _, err := NewSQLStore(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create SQL store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
// groupRule returns a rule that allows the given group.
|
||||
func groupRule(group string) *access.AccessRule {
|
||||
return &access.AccessRule{Allow: access.RuleSet{
|
||||
Users: access.StringSet{},
|
||||
Groups: access.StringSet{group: {}},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestDeleteGroupWithRules_CommitsAllChanges(t *testing.T) {
|
||||
store := newGroupTestStore(t)
|
||||
keep := access.RuleKey{Source: "src", Path: "/keep"}
|
||||
drop := access.RuleKey{Source: "src", Path: "/drop"}
|
||||
if err := store.SaveGroup("acme", access.StringSet{"alice": {}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SaveAccessRule(keep.Source, keep.Path, groupRule("acme")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SaveAccessRule(drop.Source, drop.Path, groupRule("acme")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// keep is rewritten (now allows "other"), drop is removed, the group row is deleted.
|
||||
err := store.DeleteGroupWithRules("acme", []access.RuleUpsert{{RuleKey: keep, Rule: groupRule("other")}}, []access.RuleKey{drop})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteGroupWithRules: %v", err)
|
||||
}
|
||||
|
||||
groups, err := store.GetAllGroups()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := groups["acme"]; ok {
|
||||
t.Fatal("group row should be deleted")
|
||||
}
|
||||
rules, err := store.GetAllAccessRules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := rules["src"]["/drop"]; ok {
|
||||
t.Fatal("dropped rule row should be deleted")
|
||||
}
|
||||
kept, ok := rules["src"]["/keep"]
|
||||
if !ok {
|
||||
t.Fatal("kept rule row should still exist")
|
||||
}
|
||||
if _, has := kept.Allow.Groups["other"]; !has {
|
||||
t.Fatalf("kept rule should have been rewritten, got %+v", kept.Allow.Groups)
|
||||
}
|
||||
// Deleting again (group row already absent) is not an error.
|
||||
if err := store.DeleteGroupWithRules("acme", nil, nil); err != nil {
|
||||
t.Fatalf("repeat delete should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGroupWithRules_RollsBackOnFailure(t *testing.T) {
|
||||
store := newGroupTestStore(t)
|
||||
if err := store.SaveGroup("acme", access.StringSet{"alice": {}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Make the rule statements fail so the transaction has to roll back.
|
||||
if _, err := store.db.Exec(`DROP TABLE access_rules`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := store.DeleteGroupWithRules("acme", []access.RuleUpsert{{RuleKey: access.RuleKey{Source: "src", Path: "/x"}, Rule: groupRule("other")}}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the rule write fails")
|
||||
}
|
||||
|
||||
groups, gErr := store.GetAllGroups()
|
||||
if gErr != nil {
|
||||
t.Fatal(gErr)
|
||||
}
|
||||
if _, ok := groups["acme"]; !ok {
|
||||
t.Fatal("group row must survive a failed transaction")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(testutil.Main(m))
|
||||
}
|
||||
@@ -124,6 +124,28 @@ func createSchema(db *sql.DB) error {
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_user_created ON activity_log(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_event_created ON activity_log(event_type, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_user_event_created ON activity_log(user_id, event_type, created_at DESC);
|
||||
|
||||
-- Folder quotas (path-bound caps; meter: index_size | accounted)
|
||||
CREATE TABLE IF NOT EXISTS quotas (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
limit_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
meter TEXT NOT NULL DEFAULT 'index_size',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_quotas_source ON quotas(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_quotas_source_path ON quotas(source, path);
|
||||
|
||||
-- Accounted usage counters (scope, share, optional folder accounted in future)
|
||||
CREATE TABLE IF NOT EXISTS quota_counters (
|
||||
quota_id TEXT PRIMARY KEY NOT NULL,
|
||||
used_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
reserved_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
_, err := db.Exec(schema)
|
||||
@@ -184,6 +206,9 @@ func runMigrations(db *sql.DB, fromVersion int) error {
|
||||
case 1:
|
||||
// Canonical schema is createSchema + Bolt import; no step migrations.
|
||||
case 2:
|
||||
if err := migrateV2(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := normalizeLegacyShareTokens(db); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -209,6 +234,48 @@ func runMigrations(db *sql.DB, fromVersion int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateV2(db *sql.DB) error {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS quotas (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
limit_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
meter TEXT NOT NULL DEFAULT 'index_size',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_quotas_source ON quotas(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_quotas_source_path ON quotas(source, path);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quota_counters (
|
||||
quota_id TEXT PRIMARY KEY NOT NULL,
|
||||
used_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
reserved_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return fmt.Errorf("migrate v2: %w", err)
|
||||
}
|
||||
return migrateV2AddMeterColumn(db)
|
||||
}
|
||||
|
||||
func migrateV2AddMeterColumn(db *sql.DB) error {
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('quotas') WHERE name = 'meter'`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("migrate v2 meter column check: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.Exec(`ALTER TABLE quotas ADD COLUMN meter TEXT NOT NULL DEFAULT 'index_size'`); err != nil {
|
||||
return fmt.Errorf("migrate v2 add meter column: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addHashedTokenSessionColumn adds the is_session flag to pre-existing
|
||||
// hashed_tokens tables. Existing rows are treated as non-session (API) tokens.
|
||||
func addHashedTokenSessionColumn(db *sql.DB) error {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
|
||||
)
|
||||
|
||||
func scanQuotaUserID(s string, dest *uint64) error {
|
||||
if s == "" {
|
||||
*dest = 0
|
||||
return nil
|
||||
}
|
||||
u, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse quota user_id: %w", err)
|
||||
}
|
||||
*dest = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func quotaUserIDDB(id uint64) string {
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatUint(id, 10)
|
||||
}
|
||||
|
||||
// GetAllFolderQuotas loads every folder quota row.
|
||||
func (s *SQLStore) GetAllFolderQuotas() ([]quota.FolderQuota, error) {
|
||||
rows, err := s.db.Query(`SELECT id, source, path, user_id, limit_bytes, meter FROM quotas`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query quotas: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []quota.FolderQuota
|
||||
for rows.Next() {
|
||||
var q quota.FolderQuota
|
||||
var userIDStr string
|
||||
if err := rows.Scan(&q.ID, &q.Source, &q.Path, &userIDStr, &q.LimitBytes, &q.Meter); err != nil {
|
||||
return nil, fmt.Errorf("scan quota: %w", err)
|
||||
}
|
||||
if err := scanQuotaUserID(userIDStr, &q.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetFolderQuotaByID returns one folder quota.
|
||||
func (s *SQLStore) GetFolderQuotaByID(id string) (*quota.FolderQuota, error) {
|
||||
var q quota.FolderQuota
|
||||
var userIDStr string
|
||||
err := s.db.QueryRow(`SELECT id, source, path, user_id, limit_bytes, meter FROM quotas WHERE id = ?`, id).
|
||||
Scan(&q.ID, &q.Source, &q.Path, &userIDStr, &q.LimitBytes, &q.Meter)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("quota not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get quota: %w", err)
|
||||
}
|
||||
if err := scanQuotaUserID(userIDStr, &q.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
// GetFolderQuotasForSourcePath returns quotas for a source and optional path prefix.
|
||||
func (s *SQLStore) GetFolderQuotasForSourcePath(source, path string) ([]quota.FolderQuota, error) {
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
query := `SELECT id, source, path, user_id, limit_bytes, meter FROM quotas WHERE source = ?`
|
||||
args := []interface{}{source}
|
||||
if path != "" {
|
||||
query += ` AND (path = ? OR path LIKE ?)`
|
||||
args = append(args, path, path+"/%")
|
||||
}
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query quotas for source: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []quota.FolderQuota
|
||||
for rows.Next() {
|
||||
var q quota.FolderQuota
|
||||
var userIDStr string
|
||||
if err := rows.Scan(&q.ID, &q.Source, &q.Path, &userIDStr, &q.LimitBytes, &q.Meter); err != nil {
|
||||
return nil, fmt.Errorf("scan quota: %w", err)
|
||||
}
|
||||
if err := scanQuotaUserID(userIDStr, &q.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SaveFolderQuota inserts or updates a folder quota.
|
||||
func (s *SQLStore) SaveFolderQuota(q *quota.FolderQuota) error {
|
||||
meter := q.Meter
|
||||
if meter == "" {
|
||||
meter = quota.MeterIndexSize
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO quotas (id, source, path, user_id, limit_bytes, meter, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET source=excluded.source, path=excluded.path, user_id=excluded.user_id, limit_bytes=excluded.limit_bytes, meter=excluded.meter`,
|
||||
q.ID, q.Source, q.Path, quotaUserIDDB(q.UserID), q.LimitBytes, meter, currentTimestamp(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save quota: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteFolderQuota removes a folder quota and its counter.
|
||||
func (s *SQLStore) DeleteFolderQuota(id string) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM quotas WHERE id = ?`, id); err != nil {
|
||||
return fmt.Errorf("delete quota: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`DELETE FROM quota_counters WHERE quota_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("delete quota counter: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllQuotaCounters loads all quota counters.
|
||||
func (s *SQLStore) GetAllQuotaCounters() ([]quota.Counter, error) {
|
||||
rows, err := s.db.Query(`SELECT quota_id, used_bytes, reserved_bytes FROM quota_counters`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query quota counters: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []quota.Counter
|
||||
for rows.Next() {
|
||||
var c quota.Counter
|
||||
if err := rows.Scan(&c.QuotaID, &c.UsedBytes, &c.ReservedBytes); err != nil {
|
||||
return nil, fmt.Errorf("scan quota counter: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetQuotaCounter returns one counter row.
|
||||
func (s *SQLStore) GetQuotaCounter(quotaID string) (*quota.Counter, error) {
|
||||
var c quota.Counter
|
||||
err := s.db.QueryRow(`SELECT quota_id, used_bytes, reserved_bytes FROM quota_counters WHERE quota_id = ?`, quotaID).
|
||||
Scan(&c.QuotaID, &c.UsedBytes, &c.ReservedBytes)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("quota counter not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get quota counter: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// EnsureQuotaCounter creates a counter row if missing.
|
||||
func (s *SQLStore) EnsureQuotaCounter(quotaID string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO quota_counters (quota_id, used_bytes, reserved_bytes, version, updated_at)
|
||||
VALUES (?, 0, 0, 0, ?)
|
||||
ON CONFLICT(quota_id) DO NOTHING`,
|
||||
quotaID, currentTimestamp(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure quota counter: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteQuotaCounter removes a counter row.
|
||||
func (s *SQLStore) DeleteQuotaCounter(quotaID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM quota_counters WHERE quota_id = ?`, quotaID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete quota counter: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkUpsertQuotaCounters persists dirty counters.
|
||||
func (s *SQLStore) BulkUpsertQuotaCounters(counters []quota.Counter) error {
|
||||
if len(counters) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin quota counter tx: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
stmt, err := tx.Prepare(
|
||||
`INSERT INTO quota_counters (quota_id, used_bytes, reserved_bytes, version, updated_at)
|
||||
VALUES (?, ?, ?, 0, ?)
|
||||
ON CONFLICT(quota_id) DO UPDATE SET
|
||||
used_bytes = excluded.used_bytes,
|
||||
reserved_bytes = excluded.reserved_bytes,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare quota counter upsert: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
now := currentTimestamp()
|
||||
for _, c := range counters {
|
||||
if _, err := stmt.Exec(c.QuotaID, c.UsedBytes, c.ReservedBytes, now); err != nil {
|
||||
return fmt.Errorf("upsert quota counter %s: %w", c.QuotaID, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -32,20 +32,36 @@ func (s *SQLStore) GetSetting(key string) ([]byte, error) {
|
||||
|
||||
// SaveSetting inserts or updates a setting
|
||||
func (s *SQLStore) SaveSetting(key string, value interface{}) error {
|
||||
query := `INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`
|
||||
if err := s.saveSettingQuery(s.db.Exec, query, key, value); err != nil {
|
||||
return fmt.Errorf("failed to save setting: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type settingExec func(query string, args ...interface{}) (sql.Result, error)
|
||||
|
||||
func (s *SQLStore) saveSettingQuery(exec settingExec, query, key string, value interface{}) error {
|
||||
valueJSON, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal setting value: %w", err)
|
||||
}
|
||||
|
||||
query := `INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`
|
||||
_, err = s.db.Exec(query, key, valueJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save setting: %w", err)
|
||||
if _, err := exec(query, key, valueJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) saveSettingInTx(tx *sql.Tx, key string, value interface{}) error {
|
||||
query := `INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`
|
||||
return s.saveSettingQuery(tx.Exec, query, key, value)
|
||||
}
|
||||
|
||||
// SaveSettingTx inserts or updates a setting within an existing transaction.
|
||||
func (s *SQLStore) SaveSettingTx(tx *sql.Tx, key string, value interface{}) error {
|
||||
return s.saveSettingInTx(tx, key, value)
|
||||
}
|
||||
|
||||
// DeleteSetting deletes a setting by key
|
||||
func (s *SQLStore) DeleteSetting(key string) error {
|
||||
query := `DELETE FROM settings WHERE key = ?`
|
||||
|
||||
@@ -64,6 +64,7 @@ func APIScopesToBackend(apiScopes []FrontendScope) ([]BackendScope, error) {
|
||||
Path: source.Path,
|
||||
Scope: scope.Scope,
|
||||
Permissions: frontendScopePermissions(scope),
|
||||
Quota: scope.Quota,
|
||||
})
|
||||
}
|
||||
return newScopes, nil
|
||||
@@ -86,6 +87,7 @@ func (u *User) GetFrontendScopes() []FrontendScope {
|
||||
Name: source.Name,
|
||||
Scope: scope.Scope,
|
||||
Permissions: &perms,
|
||||
Quota: scope.Quota,
|
||||
})
|
||||
}
|
||||
return newScopes
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Loaded 100 of 742 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user