Compare commits

..
Author SHA1 Message Date
Graham SteffaniakandCursor 2c071d1e6e fix(cli): preserve admin credentials in setup-generated config (#3267)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 16:19:34 -05:00
Graham Steffaniak b9afea9368 adjusted symlink extraction behavior to correct edge case (#3266) 2026-10-23 15:45:47 -05:00
a91bff2638 backport preview and share fixes to stable (#3264)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-23 15:29:41 -05:00
e02284183a fix(auth): API token permission handling for customized tokens (#3256)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-22 15:19:49 -05:00
05140f0ae3 fix: resolve staging/v2.0.2 release blockers (access perf revert, prompt cancel, tooltip dark mode) (#3239)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 11:25:49 -05:00
735842fd5f fix(sse): lock client registry during source-update delivery (GHSA-p9wm-f5jj-75pj) [stable] (#3208)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-19 19:00:10 -05:00
f0761c6146 fix(shares): preserve share paths when a folder is moved (GHSA-26fh-r34c-qxh6) [stable] (#3210)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-19 18:52:48 -05:00
Graham Steffaniak 9c812913c6 fix(frontend): restore upload guards while uploads are paused (stable) (#3221) 2026-10-19 13:52:07 -05:00
Cursor AgentandGraham Steffaniak e5e4fc4ec4 Merge branch 'staging/v2.0.2' into cursor/fix-upload-prompt-guard-stable-b1a8
Resolve Prompts.vue conflict by keeping upload guards in store mutations
and uploadNavigationGuard (PR refactor). CHANGELOG retains all v2.0.2-stable
security backports and upload guard bug fix entry.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-19 18:47:08 +00:00
Graham Steffaniak f8b7ee1a92 fix(frontend): escape Size Analyzer tooltip content (GHSA-rmjp-pfrq-cc3v) [stable] (#3212) 2026-10-19 12:41:28 -05:00
Graham Steffaniak 9e973c3959 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-rmjp-7fd7 2026-10-19 12:40:56 -05:00
Graham Steffaniak 7e97200e0a fix(auth): require password for passkey lifecycle changes (GHSA-r744-4vm3-2px3) [stable] (#3213) 2026-10-19 12:00:56 -05:00
Graham Steffaniak 44757aae7b Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-r744-7fd7 2026-10-19 12:00:31 -05:00
Graham Steffaniak 2d9eceadd6 fix(access): resolve filesystem aliases for path rules (GHSA-4vrc-935w-xxq2) [stable] (#3214) 2026-10-19 11:39:35 -05:00
Graham Steffaniak d13f5fd39d Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-4vrc-7fd7 2026-10-19 11:39:15 -05:00
Cursor AgentandGraham Steffaniak 3e0c93a435 fix(frontend): use staging api import paths in auth.js
The backport imported @/api/utils.ts and other dev-only module paths.
On staging/v2.0.2 the API layer uses utils.js and barrel imports from
@/store and @/notify.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-19 16:38:18 +00:00
Graham Steffaniak 04ae7b4764 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-r744-7fd7 2026-10-19 11:36:01 -05:00
Graham Steffaniak 22d5441d85 fix(stream): reject cross-context view grants on authenticated view API (GHSA-wxj5-g3qp-g732) [stable] (#3207) 2026-10-19 11:27:08 -05:00
Graham Steffaniak c3f340c7b6 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-wxj5-7fd7 2026-10-19 11:26:59 -05:00
Graham Steffaniak f044bd4266 fix(tools): scope duplicate finder cache per user (GHSA-4fhx-8pf6-5gmr) [stable] (#3209) 2026-10-19 11:26:22 -05:00
Graham Steffaniak 6ad9b8ea47 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-4fhx-7fd7 2026-10-19 11:26:10 -05:00
Cursor AgentandGraham Steffaniak 8e749deff2 fix(access): drop FS path cache; order alias resolution safely
Read directory entries from disk on each Permitted check. Resolve segments
with exact name, then case-fold + SameFile, then identity with ambiguity denied.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 22:37:38 +00:00
Cursor AgentandGraham Steffaniak bf8c18cf89 fix(frontend): clear stale upload navigation approval when idle
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 22:25:09 +00:00
Graham Steffaniak bb05475d83 Merge branch 'staging/v2.0.2' into cursor/fix-upload-prompt-guard-stable-b1a8 2026-10-18 17:22:39 -05:00
Graham Steffaniak db41b621d1 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-rmjp-7fd7 2026-10-18 17:12:01 -05:00
Graham Steffaniak e323f02d9e fix(webdav): require Create for new PUT targets (GHSA-8655-2ph3-q7q7) [stable] (#3211) 2026-10-18 17:11:42 -05:00
Graham Steffaniak c120419016 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-8655-7fd7 2026-10-18 17:11:31 -05:00
Graham Steffaniak 3ae4c2841f fix(auth): trust rightmost X-Forwarded-For for client IP (GHSA-wjp8-g49g-xrhx) [stable] (#3205) 2026-10-18 17:10:49 -05:00
Graham Steffaniak 4c05a913b9 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-wjp8-7fd7 2026-10-18 17:10:38 -05:00
Graham Steffaniak 865a1103c7 Merge branch 'staging/v2.0.2' into cursor/backport-ghsa-rmjp-7fd7 2026-10-18 17:08:53 -05:00
Graham Steffaniak ae2cb9f22a fix(frontend): files route parsing and /files/ default source (stable backport) (#3216) 2026-10-18 17:01:00 -05:00
Cursor AgentandGraham Steffaniak 15e803079f fix(frontend): keep staging router next() API for upload nav guard
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 21:59:48 +00:00
Cursor AgentandGraham Steffaniak dbd845e2e2 fix(frontend): harden upload nav guard and isUploading on queue removal
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 21:58:55 +00:00
Cursor AgentandGraham Steffaniak 177f48e5d7 fix(frontend): harden upload nav guard and isUploading on queue removal
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 21:57:29 +00:00
Cursor AgentandGraham Steffaniak 9e4f391f93 fix(access): resolve FS aliases via ReadDir and SameFile (GHSA-4vrc)
Replace ineffective Lstat().Name() resolution with per-segment directory
listing, SameFile matching, and fail-closed errors; add tests and path cache.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 21:56:45 +00:00
Cursor AgentandGraham Steffaniak 5d483e7953 fix(frontend): use next(false) for upload nav guard on staging router API
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 19:57:40 +00:00
Cursor AgentandGraham Steffaniak 865ccb3ae6 fix(frontend): guard upload prompt while uploads are paused
Treat paused queue items as in-progress for isUploading, centralize
closeTopPrompt protection in the store, add SPA navigation guard, and
keep beforeunload tied to isUploading instead of the Upload component.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 19:07:01 +00:00
Cursor AgentandGraham Steffaniak 74157deb3b fix(frontend): reject passkey password prompt on dismiss
Wire showPrompt cancel when the user closes the password dialog (X/Escape)
via Prompts.vue. Export isPasskeyActorPasswordCancelled and skip error toasts
in passkey settings UI when the user backs out.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 19:03:32 +00:00
Cursor AgentandGraham Steffaniak f168ce087d fix(auth): gate passkey password checks on password login; send X-Password from UI
Mirror GHSA-r744 beta fix (#3198): password-login accounts only for
checkOtpActorPassword; frontend prompts and sends X-Password on register/delete.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:58:17 +00:00
Cursor AgentandGraham Steffaniak c524a7d752 docs(changelog): use v2.0.2-stable section for GHSA-4vrc
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:39 +00:00
Cursor AgentandGraham Steffaniak f79f514a29 docs(changelog): use v2.0.2-stable section for GHSA-r744
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:38 +00:00
Cursor AgentandGraham Steffaniak 93bfa4906d docs(changelog): use v2.0.2-stable section for GHSA-rmjp
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:37 +00:00
Cursor AgentandGraham Steffaniak 18b8ed624f docs(changelog): use v2.0.2-stable section for GHSA-8655
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:36 +00:00
Cursor AgentandGraham Steffaniak a470e65a24 docs(changelog): use v2.0.2-stable section for GHSA-4fhx
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:33 +00:00
Cursor AgentandGraham Steffaniak e18b8683f5 docs(changelog): use v2.0.2-stable section for GHSA-wxj5
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:31 +00:00
Cursor AgentandGraham Steffaniak d03ee259a2 docs(changelog): use v2.0.2-stable section for GHSA-wjp8
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:54:28 +00:00
Graham Steffaniak 8a2f4a25d4 Refactor Tooltip and generic to avoid v-html usage (GHSA-rmjp-pfrq-cc3v) [stable] (#3217) 2026-10-18 13:07:24 -05:00
Cursor AgentandGraham Steffaniak 0d708f3955 fix(frontend): minimal /files/ and source-named-files routing (stable)
Backport #3099 knownSources guard and #3178 single-source fallback at
/files/ without changing multi-source redirect URLs.

Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 18:04:52 +00:00
Kevin B. 55c659e332 Refactor Tooltip.vue and generic.vue 2026-10-18 12:38:22 -05:00
Cursor AgentandGraham Steffaniak f095bc64ee fix(changelog): v2.0.2 security entry for GHSA-4fhx backport
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:43:56 +00:00
Cursor AgentandGraham Steffaniak 96efc21e88 fix(webdav): require Create for new PUT targets (GHSA-8655-2ph3-q7q7)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:42:42 +00:00
Cursor AgentandGraham Steffaniak 7b85410b50 fix(access): resolve filesystem aliases for access rules (GHSA-4vrc-935w-xxq2)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:40:52 +00:00
Cursor AgentandGraham Steffaniak c14a1af81e fix(auth): require password to change passkeys (GHSA-r744-4vm3-2px3)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:40:46 +00:00
Cursor AgentandGraham Steffaniak e8c85e3f90 fix(frontend): escape Size Analyzer tooltip content (GHSA-rmjp-pfrq-cc3v)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:40:45 +00:00
Cursor AgentandGraham Steffaniak c1f0033c65 fix(view): reject share view tokens on authenticated view route (GHSA-wxj5-g3qp-g732)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:40:41 +00:00
Cursor AgentandGraham Steffaniak a2d48144ea fix(tools): scope duplicate finder cache per user (GHSA-4fhx-8pf6-5gmr)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:39:49 +00:00
Cursor AgentandGraham Steffaniak 024d383b14 fix(auth): trust rightmost X-Forwarded-For for client IP (GHSA-wjp8-g49g-xrhx)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-18 16:37:50 +00:00
Graham Steffaniak aa4dd3ae95 updated v2.0.1 changelog (#3141) 2026-10-13 09:53:11 -05:00
Graham Steffaniak 9f4b15442b fix: reject usernames that do not map to their own home directory (#3139) 2026-10-13 09:33:41 -05:00
Graham Steffaniak 759342bef3 updated changelog 2026-10-13 09:12:40 -05:00
dl-pt c4072608d6 fix(auth): prevent collisions between newly issued tokens (#3135) 2026-10-13 09:09:21 -05:00
Graham Steffaniak a29573e706 updated with stable changelog (#3104) 2026-10-09 10:57:05 -05:00
Graham Steffaniak 1071e66a36 updated changelog 2026-10-09 10:45:47 -05:00
Graham Steffaniak 3956ccd859 make sidebar tree view links native hyperlinks (#3080) 2026-10-04 16:19:09 -05:00
Graham Steffaniak 3f756de845 port fix over to dev/v2.0.10 2026-10-04 15:58:39 -05:00
Graham Steffaniak aa98563db7 Merge branch 'dev/v2.0.10' of github.com:gtsteffaniak/filebrowser into dev/v2.0.10 2026-10-04 15:57:57 -05:00
Graham Steffaniak fb7f25b0a8 Fix bugs halloween prompts (#3076) 2026-10-04 15:38:16 -05:00
Graham Steffaniak 7f56deb03b Merge branch 'dev/v2.0.10' of github.com:gtsteffaniak/filebrowser into dev/v2.0.10 2026-10-04 12:27:11 -05:00
Graham Steffaniak 592d8db506 Update version match workflow (#3072) 2026-10-04 12:26:59 -05:00
Graham Steffaniak 0b346bd8c7 Merge branch 'dev/v2.0.10' of github.com:gtsteffaniak/filebrowser into dev/v2.0.10 2026-10-04 12:04:49 -05:00
Graham Steffaniak 8f4e148cf3 updated cli setup command (#3071) 2026-10-04 12:04:37 -05:00
Graham Steffaniak 82ab5957dd updated cli setup command 2026-10-04 11:50:18 -05:00
Graham Steffaniak 033e1a4642 upgrade docker 2026-10-04 11:33:30 -05:00
Graham SteffaniakandCursor 78cb4ca214 Fix GHSA-6gr6-5qpq-888p: API token session renew (#3070)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 11:29:14 -05:00
Graham SteffaniakandCursor b40be3e25e Fix TOTP re-enrollment MFA bypass (GHSA-qx86-4v5r-26g5). (#3067)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 10:49:36 -05:00
Graham Steffaniak e4fc79287d added feature to reset user password on login (#3064) 2026-10-03 20:00:01 -05:00
Graham SteffaniakandCursor 9ae8cc07e6 fix: GHSA-p7x3-p5jj-9xfh - Enforce public share limits on lyrics and subtitle routes. (#3061)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:20:01 -05:00
Graham Steffaniak b2936e5c80 Fix token expiration (#3056) 2026-10-03 16:54:11 -05:00
Kevin B. f501c765a6 fix sidebar behind header on iOS (#3060) 2026-10-03 16:18:01 -05:00
Graham SteffaniakandDevin b861660ce9 Fix inflated disk usage on ZFS datasets and btrfs subvolumes; add roo… (#3035)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-02 16:16:31 -05:00
Graham Steffaniak 98c96c3ec4 Case insensitive match ldap (#3048) 2026-10-01 22:05:58 -05:00
Graham Steffaniak b7e06fd333 added support for env vars in config (#3047) 2026-09-30 17:53:01 -05:00
513 changed files with 15609 additions and 37340 deletions

No files matched your search

-15
View File
@@ -1,15 +0,0 @@
root = true
[*]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
[*.go]
indent_size = 4
[{Makefile,makefile}]
indent_style = tab
+101
View File
@@ -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"
-41
View File
@@ -35,44 +35,3 @@ jobs:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_beta_release:
needs: [test_playwright]
if: startsWith(github.event.head_commit.message, 'Beta/v')
name: Create Beta Release
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
fetch-depth: 0
- name: Extract version from commit message
id: extract_version
shell: bash
run: |
tag_name=$(echo "${{ github.event.head_commit.message }}" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
if [ -z "$tag_name" ] || [ "$tag_name" = "-beta" ]; then
echo "Could not extract version from: ${{ github.event.head_commit.message }}"
exit 1
fi
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
- name: Tag release commit on main
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
TAG="${{ steps.extract_version.outputs.tag_name }}"
git tag -f "$TAG" "${{ github.sha }}"
git push origin "refs/tags/$TAG" --force
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_version.outputs.tag_name }}
prerelease: false
make_latest: false
draft: false
generate_release_notes: true
overwrite_existing: true
name: ${{ steps.extract_version.outputs.tag_name }}
+2 -8
View File
@@ -43,13 +43,7 @@ jobs:
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i
- name: Lint frontend
working-directory: frontend
run: npm run lint
- name: Lint frontend CSS
working-directory: frontend
run: npm run lint:css
run: npm i && npm run lint
test-frontend:
runs-on: ubuntu-24.04-arm
steps:
@@ -85,4 +79,4 @@ jobs:
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run icons:check
run: npm i && npm run icons:check
+34 -18
View File
@@ -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"
+179 -74
View File
@@ -2,73 +2,180 @@
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.0
## v2.0.2-stable
**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
- [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
- [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
- [High] File Size Analyzer hover tooltips render file paths as plain text instead of HTML, closing stored XSS via crafted file names (GHSA-rmjp-pfrq-cc3v) reporter @U3RhcnQ
- [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
- [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] 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] 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
**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).
- fix(auth): apply stored permission caps for customized API tokens with no global flags enabled; minimal tokens still inherit the owner's permissions
- Preview generation for WebP images (encode as JPEG thumbnail) ([#3259](https://github.com/gtsteffaniak/filebrowser/pull/3259), [#3254](https://github.com/gtsteffaniak/filebrowser/issues/3254))
- Public share pages show configured banner when API returns `bannerUrl` ([#3250](https://github.com/gtsteffaniak/filebrowser/pull/3250), [#3246](https://github.com/gtsteffaniak/filebrowser/issues/3246))
- Tar extraction creates hard-linked files instead of skipping `TypeLink` entries ([#3234](https://github.com/gtsteffaniak/filebrowser/pull/3234))
- Embedded JPEG previews for additional RAW/camera formats (ARW, RW2, RAW, RWL) ([#3243](https://github.com/gtsteffaniak/filebrowser/pull/3243))
- fix(preview): generate WebP image previews by encoding thumbnails as JPEG ([#3259](https://github.com/gtsteffaniak/filebrowser/pull/3259)) ([#3254](https://github.com/gtsteffaniak/filebrowser/issues/3254))
- fix(frontend): show configured share banner on public share pages when API returns `bannerUrl` ([#3250](https://github.com/gtsteffaniak/filebrowser/pull/3250)) ([#3246](https://github.com/gtsteffaniak/filebrowser/issues/3246))
- fix(archive): extract tar hard-linked files instead of skipping `TypeLink` entries ([#3234](https://github.com/gtsteffaniak/filebrowser/pull/3234))
- fix(preview): embedded JPEG previews for additional RAW/camera formats (RTF, RW2, RAW, RWL) ([#3243](https://github.com/gtsteffaniak/filebrowser/pull/3243))
- fix(frontend): keep source named `files` in `extractSourceFromPath` ([#3143](https://github.com/gtsteffaniak/filebrowser/issues/3143), [#3099](https://github.com/gtsteffaniak/filebrowser/pull/3099))
- fix(frontend): load the default source when browsing `/files/` with a single configured source ([#3178](https://github.com/gtsteffaniak/filebrowser/issues/3178)) (related: [#3144](https://github.com/gtsteffaniak/filebrowser/issues/3144))
- fix(frontend): restore upload prompt close, tab-reload, and in-app navigation guards while uploads are active (including paused uploads)
## v2.0.1-stable
**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
## v2.0.0-stable
This version represents the most significant change to date. It **requires** both a database migration and config structural changes. See the [migration guide](https://filebrowserquantum.com/en/docs/getting-started/v2/migration/) for step-by-step upgrade instructions, [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) for a full summary.
Also, consider enabling "analytics" in admin settings which will help me populate an anonymous public dashboard for everyone to see. Currently, theres 60 deployments details populated from beta, so after a percentage of you enable this in staging there should be plenty to make the dashboard useful and available. No private information is used, you can see exactly what data is sent in the UI.
**Breaking Changes**:
- Removed: `GET /api/raw` and `GET /public/api/raw` download routes — use `/api/resources/download` instead.
- Removed: `/share/…` URL redirect to `/public/share/…` — use `/public/share/…` directly.
- Removed: singular `source` search api param (use `sources`), bare `scope` paths without `sourceName:` prefix, and `glob` / `useGlob` aliases (use `useWildcard`).
- Removed `config.conditionals`, source-level `indexingIntervalMinutes` (indexing always uses adaptive scheduling), and deprecated rule fields `fileNames` / `folderNames` / top-level `hidden` — use `config.rules` with `fileName`, `folderName`, and `ignoreHidden` on rules. See [Exclusion rules](https://filebrowserquantum.com/en/docs/user-guides/general-configuration/exclusion-rules/).
- Removed: deprecated `userDefaults` config formats (nested and flat) — use the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert before upgrading.
- Changed: `PUT /api/users` moved to the more appropriate `PATCH` method and requires specifying `which` in the body. Blank or `all` values are rejected.
- Changed: http related config options in `server` config key moved to `http` config key. See [HTTP settings](https://filebrowserquantum.com/en/docs/configuration/http/).
- Changed (reverse proxy): `http.trustedHeaders` (v1.5.x list) removed — use `http.trustProxyHeaders: true` when behind nginx, Traefik, or Caddy. When enabled, FileBrowser honors `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-For`, and `X-Real-IP` for client IP, cookies, OIDC callbacks, WebAuthn, share URLs, rate limiting, and activity logs. Default is `false` (direct connection values). The [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) converts v1 `trustedHeaders` lists to `trustProxyHeaders: true`. See [Reverse proxy](https://filebrowserquantum.com/en/docs/getting-started/reverse-proxy/) and [HTTP trustProxyHeaders](https://filebrowserquantum.com/en/docs/configuration/http/#trustproxyheaders).
- Changed: `FILEBROWSER_DATABASE` environment variable — use `FILEBROWSER_DATABASE_PATH` instead. See [Environment variables](https://filebrowserquantum.com/en/docs/reference/environment-variables/) and [Server settings](https://filebrowserquantum.com/en/docs/configuration/server/).
- Changed: Moved stream api to `/api/media/stream`. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
- Changed: CLI user management — canonical commands are `user set <username> --password [value]` and `user promote <username>`; `set -u username,password` is deprecated. See [CLI reference](https://filebrowserquantum.com/en/docs/reference/cli/).
**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.
- [High] Restricted custom API tokens can no longer be upgraded to a full-permission session via `POST /api/auth/renew` (GHSA-6gr6-5qpq-888p) -- thanks @tao0845.
- [High] TOTP re-enrollment no longer allows anonymous callers to replace an existing second factor using only the account password; requires an authenticated self or admin session (GHSA-qx86-4v5r-26g5) -- thanks @tao0845.
- [Medium] Public upload shares with `allowReplacements=false` now reject overwrites when clients send `override=true`. (GHSA-3846-gh75-gp3m) Thanks @d3do-23
- [Medium] OnlyOffice callbacks verify the shared `integrations.office.secret` JWT and require the document key to match the cached editor session; document downloads re-validate redirect targets against the configured document-server host (SSRF); public shares with `enableOnlyOffice=false` reject `/office/*` requests server-side.
- [Low] Public share lyrics and subtitle routes honor the share's file-viewer setting, download disable flag, and download limits (GHSA-p7x3-p5jj-9xfh) -- thanks Yves Soete of Blacksight LLC. @yssoe
- Fresh installs with default `admin`/`admin` generate a random initial password and log it once (#2977).
- OIDC login binds OAuth `state` to an HttpOnly cookie and rejects tampered callbacks; login/logout/session-expiry redirects reject open-redirect targets, and signup sends credentials in a JSON body instead of query parameters.
- Startup fails when the configured auth signing key differs from the persisted one; session/API token registrations record JWT expiry and expired mappings are pruned.
**New Features**:
- View grant mechanism to distinguish between UI viewing and download. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
- granular per-source file permissions (view, download, modify, create, delete) with automatic migration from global permissions
- per-source defaults configurable in `settings > access management`
- `view` permission is automatically set to true unless explicitly set to false. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
- can be enforced for all users. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
- New activity logs for user activity.
- charts and historical data
- export to csv reports
- Media player improvements:
- Refreshed playback queue UI: Supports thumbnails, stored into session storage, and has a "clear queue" button (#2575) (#2600).
- Loop now has 3 states (off/single/all) and neither of them will clear the existing queue (#2600).
- New "Audio visualizer" for audio files (desktop-only), you can configure some basic things to your taste (#2575) (#2620).
- The current state of the audio panel now is stored into local storage.
- More gestures: Swipe up to enter/exit fullscreen, long-press to change playback speed, single tap to pause (#2575).
- Videos now will resume fullscreen and PiP when navigating (queue auto-navigation, swipes gestures or next/previous) (#2649).
- Added `F4` shortcut to refresh the current directory and metadata (#2600).
- 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.
- 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.
- WebDAV `COPY` preserves modification times only for files, is limitation we have with webdav.
- User default enhancements
- Config `userDefaults` seeds SQLite on first run; only fields explicitly set in config stay locked in **Settings → User defaults** (other defaults remain editable)
- Added administrator controls for universal user defaults and enforced preferences in `settings > user management > user defaults`.
- Added configurable default file permissions per source in `settings > access management`.
- Added a User Defaults editor for account, permission, and profile preferences in the edit/create user prompt. See [User management](https://filebrowserquantum.com/en/docs/configuration/users/).
- Database env var rename: `FILEBROWSER_DATABASE` is removed (startup fails if set). Use `FILEBROWSER_DATABASE_PATH` (default `filebrowser.sqlite`) or `server.database.path` in config. See [Environment variables](https://filebrowserquantum.com/en/docs/reference/environment-variables/) and [Server settings](https://filebrowserquantum.com/en/docs/configuration/server/).
- CLI: `user set` with `--password` (inline value, interactive prompt on TTY, or piped stdin); `user promote` for admin grant without password reset. See [CLI reference](https://filebrowserquantum.com/en/docs/reference/cli/).
- Require password change at next login for password-based users: new user setting `requirePasswordChange` (user defaults + per-user admin toggle). 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.
- PWA improvements for installed mobile apps: dedicated maskable icons (192/512), manifest and splash colors that follow the instance default theme, runtime `theme-color` sync on dark-mode toggle, and edge-to-edge safe-area layout for notched devices (#2625) (#2869) -- thanks @APatenaude
- Users can set default view mode and thumbnail size from Profile settings (Listing options). Admins can set the same defaults for existing users and edit all profile preference defaults from the user management panel ([#2884](https://github.com/gtsteffaniak/filebrowser/issues/2884)).
- Added "Upload only what's missing" to the upload conflict prompt (#2985) (#2553)
- Added `init` CLI command, which creates a minimal commented config.yaml instead of a full config (#2957)
- Config YAML expands `$VAR` and `${VAR}` so values such as `userPassword: "${FILEBROWSER_LDAP_USER_PASSWORD}"` work (#3042).
- Sidebar source links can switch between aggregated usage (default) and a root-filesystem-only view via a new "Limit disk usage to source filesystem" toggle.
**Notes**:
- v2.x.x uses a new write-through backend state management. Changes go through a fast memory layer and also write changes to database to stay in sync. See [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/).
- CLI server start (`./filebrowser`), `setup`, `version`, and `set rule` syntax unchanged; see [CLI docs](https://filebrowserquantum.com/en/docs/reference/cli/)
- new dropdown and input styles
- swipe gestures to dismiss notifications (#2672)
- user updates are more granular, don't include entire user payload.
- `user.id` has been moved to a backend property and all frontend apis now query users by username. Swagger has been updated. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
- removed legacy and deprecated properties from API responses and generated config output
- `/api/media/stream` is audio/video only (range-based chunking). Non-media inline viewing uses `GET /api/resources/view`. Both endpoints use the same `viewToken` from file metadata. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
- removed exiftool as an optional helper, always built with the supported libraries (requires 64 bit os)
- If migration issues arise, see [Migration troubleshooting](https://filebrowserquantum.com/en/docs/getting-started/migration/troubleshooting/).
- default browser media player option removed, always uses themed plyr
- [docker] upgraded ffmpeg from 8.1.2 to 9.0
- 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.
- Pop-up preview has a 200ms debounce delay so it doesn't flash when moving the cursor across files quickly.
- Improved UI responsiveness for larger directories and Firefox, with marginal memory improvement (#1773) (#2879)
- Improvements to document thumbnail generation performance.
- Changed behavior for typing to select files in listing view; added more actions in advanced search (#2776).
- Session renew is handled by client keep-alive; per-request `X-Renew-Token` header handling removed.
- `defaultEnabled` now means the source is always added to users on startup and login.
- Webdav always shows hidden files, ignores the user preference. (#3004)
- Share download links no longer embed a token; they link to the UI, which prompts for the password before download. For direct downloads use the documented `/api/share/direct` API. (#2888)
- Sidebar links follow source changes: renaming a source updates links, disabling/deleting a user source removes them (#2878) (#2942), and adding a source via scopes auto-adds a link.
- `/api/resources/download` and `/public/api/resources/download` return HTTP 404 for missing files/directories instead of 500 (#2981); `GET /api/resources` returns 400 when `path` is missing or empty (#2801).
- Added risc-v to official releases.
- A CLI password reset returns the user to a password-method user.
**Bugfixes**:
- Long uploads/downloads no longer lose the session mid-transfer; session keep-alive renews before expiry and auto-logout is disabled during active transfers (#2638).
- GroupMap changes are written through to SQL so JWT/OIDC/LDAP group memberships survive restart (#2742).
- OIDC: `groupsClaim` is always requested and falls back to UserInfo; the verified ID-token identifier is preserved on fallback; sessions respect `tokenExpirationHours` (#3006).
- LDAP `userGroups` matching accepts CN-only values against full `memberOf` DNs and is case-insensitive (#3044).
- Sidebar folder links with custom names/icons/styles persist across restarts, and deleted links stay deleted (#2809) (#2935).
- Fixed PDF thumbnail process aborts (#2763), PDF preview blocking uploads (#2752), and 401 on the PDF download button (#2978).
- Fixed ffmpeg unresponsive lock issue (#2996).
- Fixed multiple embedded subtitles with the same language (#2756).
- Fixed Fuji `.raf` thumbnail preview; unsupported image preview formats return HTTP 415 instead of 500.
- Members without download permission could not open text-based files with OnlyOffice enabled (#2777).
- Fixed iOS 26 / WebKit multi-chunk upload stall by isolating chunk connections (#2734).
- Mobile/UI fixes: upload options cut off (#2685), uploaded image cut off (#2765), next/previous buttons hiding on photos (#2767), missing gallery download button (#2767), double-tap to zoom, html viewer height, tooltips on mobile, assorted styling inconsistencies (#2908).
- Public share folder and multi-file ZIP downloads were empty for anonymous visitors on sources with deny-by-default/path rules (#2631) (#2365).
- Preserve Ctrl-click selection with stale keyboard state (#2958) (#2923); avoid false stalls in parallel transfers (#2950) (#2948) thanks @gudcks0305; hide Replace on conflict prompts when the user lacks modify permission (#2837).
- Disk usage: fixed overstatement on virtiofs/Docker Desktop (#2894), inflated usage across ZFS datasets/btrfs subvolumes (#3025) (#2997), capped usage bars at 100% and summed nested mounts on Linux (#2761) (#2238).
- Support non-ASCII share passwords (#2933); fall back to buffered copies when FUSE rejects fast paths (#2938) (#2924); tilde (`~`) source paths expand properly.
- Fixed slow/broken file listing when `http.baseURL` is a subpath.
- SQLite: removed shared cache and set `busy_timeout` on every pooled connection; busy errors no longer reported as success or empty cache results.
## 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
@@ -94,7 +201,6 @@ 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
@@ -119,7 +225,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**:
@@ -199,7 +305,6 @@ 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
@@ -273,7 +378,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.
@@ -453,14 +558,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)
@@ -606,7 +711,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)
@@ -640,7 +745,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`
@@ -803,7 +908,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)
@@ -906,7 +1011,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
@@ -941,7 +1046,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
+9 -9
View File
@@ -86,7 +86,7 @@ See the [Official Docs](https://filebrowserquantum.com/). Contributions are welc
## Comparison Chart
Application Name | <img width="48" alt="" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" > Quantum | <img width="48" alt="" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" alt="" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" alt="" src="https://cdn.iconscout.com/icon/free/png-256/free-google-drive-logo-icon-svg-download-png-2476481.png" > Google_Drive | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
--- | --- | --- | --- | --- | --- | --- |
Filesystem support | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
@@ -95,7 +95,7 @@ Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
@@ -109,25 +109,25 @@ Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
tags support | :construction: | ❌ | ✅ | ✅ | ❌ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Metrics | :construction: | ❌ | ✅ | ✅ | ❌ | ❌ |
file space quotas | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
Metrics | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
file space quotas | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
activity log | ✅ | ❌ | ✅ | ✅ | ✅ | ✅
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:9.0.1-decode AS ffmpeg
FROM ghcr.io/gtsteffaniak/ffmpeg:9.0.2-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
+1 -1
View File
@@ -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-headless-shell
RUN npx playwright install --with-deps chromium
@@ -4,8 +4,10 @@ http:
<<: *http-main
frontend:
<<: *frontend-main
auth:
<<: *auth-main
userDefaults:
preview:
image: true
+3 -3
View File
@@ -1,6 +1,9 @@
http:
port: 8080
baseURL: "/files/"
frontend:
styling:
disableEventThemes: true
auth:
adminPassword: "playwright-password"
@@ -13,6 +16,3 @@ server:
name: "playwright"
config:
defaultEnabled: true
userDefaults:
account:
showAdvancedProfile: true
-1
View File
@@ -1 +0,0 @@
FILEBROWSER_DEVMODE=true
+4 -10
View File
@@ -1,22 +1,16 @@
root = "."
tmp_dir = "tmp"
env_files = [".air.env"]
[build]
cmd = "bash ./scripts/air-build.sh"
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
entrypoint = "./tmp/filebrowser"
args_bin = ["-c", "test_config.yaml"]
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs", "internal/preview/gofitzinclude"]
poll = true
poll_interval = 1000
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs"]
log = "air.log"
delay = 1000 # ms
[build.windows]
cmd = "bash ./scripts/air-build.sh"
entrypoint = "./tmp/filebrowser.exe"
[log]
time = true
+22
View File
@@ -0,0 +1,22 @@
root = "."
tmp_dir = "tmp"
[build]
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
cmd = "go build -o ./tmp/filebrowser.exe --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
entrypoint = "./tmp/filebrowser.exe"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser.exe -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "internal\\web\\dist", "internal\\web\\embed", "internal\\preview\\testdata", "internal\\preview\\thumbnails", "internal\\preview\\heic", "swagger\\docs"]
exclude_unchanged = true
follow_symlink = false
poll = true
poll_interval = 1000
log = "air.log"
delay = 1000 # ms
[log]
time = true
[misc]
clean_on_exit = false
+5 -5
View File
@@ -28,7 +28,7 @@ require (
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.67.4 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
@@ -54,7 +54,7 @@ github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // i
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -105,7 +105,7 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indir
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/golangci-lint/v2 v2.14.0 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
@@ -150,7 +150,7 @@ github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mgechev/revive v1.17.0 // indirect
github.com/mgechev/revive v1.17.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
@@ -200,7 +200,7 @@ github.com/mgechev/revive v1.17.0 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/tdewolff/parse/v2 v2.8.16 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
github.com/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/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
-10
View File
@@ -56,8 +56,6 @@ 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=
@@ -140,8 +138,6 @@ 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=
@@ -294,8 +290,6 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog
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/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
@@ -420,8 +414,6 @@ 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-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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
@@ -575,8 +567,6 @@ 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=
+38 -20
View File
@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
@@ -225,19 +224,11 @@ func createConfig(configpath string, noInput bool) error {
fmt.Printf("Error: '%s' is not a valid port. Please enter a number between 1 and 65535.\n", portStr)
}
for {
levels := askQuestion(reader, "What should the log levels be?", "info|warning|error")
checkLevels := SplitByMultiple(levels)
invalidOptions := []string{}
for _, level := range checkLevels {
if !(level == "info" || level == "warning" || level == "error" || level == "debug") {
invalidOptions = append(invalidOptions, level)
}
}
if len(invalidOptions) == 0 {
break
}
fmt.Printf("Error: invalid options given '%s'. valid options: 'info|warning|error|debug'.\n", invalidOptions)
config.Server.Logging = []settings.LogConfig{
{
Output: "stdout",
Levels: "info|warning|error",
},
}
for {
@@ -250,9 +241,20 @@ func createConfig(configpath string, noInput bool) error {
}
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
}
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
config.Auth.Methods.PasswordAuth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
config.Auth.Methods.PasswordAuth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
const defaultFrontendName = "FileBrowser Quantum"
brandName := askQuestion(reader, "What should the application brand name be?", defaultFrontendName)
if brandName != defaultFrontendName {
config.Frontend.Name = brandName
}
adminUsername := askQuestion(reader, "What should the default admin username be?", "admin")
if adminUsername != "admin" {
config.Auth.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.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), nil)
}
}
return settings.GenerateConfigYaml(config, true, false, false, nil)
}
func generateYaml() {
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
if generateConfig || settings.Env.IsDevMode {
+24
View File
@@ -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:")
}
-12
View File
@@ -1,12 +0,0 @@
package cmd
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
+2 -2
View File
@@ -200,9 +200,9 @@ func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shu
if err != nil {
logger.Fatalf("Failed to create sub filesystem: %v", err)
}
fileutils.InitAssetFS(subAssets, true, false)
fileutils.InitAssetFS(subAssets, true)
} else {
fileutils.InitAssetFS(nil, false, settings.Env.IsDevMode)
fileutils.InitAssetFS(nil, false)
}
// Start preview service
+6 -3
View File
@@ -74,7 +74,10 @@ func validateUserInfo(newDB bool) {
if bumpToNewestVersion(user, tokenBackfillFailed) {
changedFields = append(changedFields, "version")
}
adminUser := settings.PasswordAdminUsername()
adminUser := settings.Config.Auth.AdminUsername
if adminUser == "" {
adminUser = "admin"
}
if user.Username == adminUser && user.Permissions.Admin {
adminPerms := settings.AdminPerms()
if user.Permissions.Share != adminPerms.Share || user.Permissions.Api != adminPerms.Api {
@@ -87,7 +90,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.PasswordAdminPassword()
user.Password = settings.Config.Auth.AdminPassword
user.LoginMethod = users.LoginMethodPassword
changedFields = append(changedFields, "permissions", "password", "loginMethod")
changePass = true
@@ -135,7 +138,7 @@ func dedupeFields(fields []string) []string {
}
func updateUserScopes(user *users.User) bool {
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes, user.DeclinedDefaultSources)
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes)
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
user.BackendScopes = newScopes
return changed
@@ -1,6 +1,9 @@
http:
port: 8080
baseURL: "/"
frontend:
styling:
disableEventThemes: true
server:
logging:
- levels: "info|warning|error"
-1
View File
@@ -53,7 +53,6 @@ userDefaults:
lockPassword: false
disableSettings: false
disableUpdateNotifications: false
showAdvancedProfile: false
permissions:
api: false
admin: false
+9 -8
View File
@@ -19,7 +19,7 @@ require (
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-ffmpeg v1.2.0
github.com/gtsteffaniak/go-ffmpeg v0.5.2
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.52
@@ -38,7 +38,7 @@ require (
golang.org/x/time v0.16.0
golang.org/x/tools v0.50.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.60.1
modernc.org/sqlite v1.59.0
)
require (
@@ -54,8 +54,8 @@ require (
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.2 // indirect
github.com/go-openapi/jsonreference v1.0.3 // indirect
github.com/go-openapi/jsonpointer v1.0.1 // indirect
github.com/go-openapi/jsonreference v1.0.2 // indirect
github.com/go-openapi/spec v1.0.1 // indirect
github.com/go-openapi/swag/conv v0.29.2 // indirect
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
@@ -64,12 +64,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.2 // indirect
github.com/go-playground/locales v0.14.1 // 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-20260928092222-7d12f68cfadb // indirect
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-tpm v0.9.8 // indirect
@@ -78,14 +78,14 @@ 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.1.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // 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.5 // indirect
github.com/tinylib/msgp v1.6.4 // 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
@@ -103,4 +103,5 @@ 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
)
+20 -18
View File
@@ -69,10 +69,10 @@ github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU
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.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/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg=
github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY=
github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc=
github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs=
github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk=
github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
@@ -94,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.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho=
github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak=
github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8=
github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY=
github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.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-20260928092222-7d12f68cfadb h1:z//sB+BhCYNvUrk19E8aWqNx+BBom8p/uD/nh95N/5U=
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 h1:m+G0ip1+N4CF0ex34SeojAon6htIIBwvzsyXNx1fGWg=
github.com/golang/geo v0.0.0-20260818125358-b200a1149890/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -143,8 +143,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-ffmpeg v1.2.0 h1:WSFfRL5xVUpEXBNI4D3f2Uz8yxbc3OEqTtjp4Z9yPUo=
github.com/gtsteffaniak/go-ffmpeg v1.2.0/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-ffmpeg 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-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=
@@ -187,8 +189,8 @@ 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.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -217,8 +219,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.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/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=
@@ -333,8 +335,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.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
-1
View File
@@ -1 +0,0 @@
../../frontend/dist
+1 -1
View File
@@ -53,7 +53,7 @@ func TestUserUpdateChangesFiltersUnchangedFields(t *testing.T) {
which := []string{
"preview", "stickySidebar", "darkMode", "locale", "singleClick", "sorting",
"showHidden", "dateFormat", "themeColor", "quickDownload", "fileLoading",
"sidebarLinks", "deleteAfterArchive", "preferEditorForMarkdown", "showFirstLogin",
"sidebarLinks", "deleteAfterArchive", "preferEditorForMarkdown", "showFirstLogin", "requirePasswordChange",
}
changes := UserUpdateChanges(before, &after, which, false)
-74
View File
@@ -7,7 +7,6 @@ import (
"time"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
@@ -288,79 +287,6 @@ func RecordAccessDelete(r *http.Request, actor *Actor, source, path string, chan
})
}
func RecordQuotaCreate(r *http.Request, actor *Actor, source, path string, changes []activitydb.FieldChange) {
RecordQuotaMutation(r, actor, activitydb.EventQuotaCreate, source, path, changes)
}
func RecordQuotaUpdate(r *http.Request, actor *Actor, source, path string, changes []activitydb.FieldChange) {
RecordQuotaMutation(r, actor, activitydb.EventQuotaUpdate, source, path, changes)
}
func RecordQuotaDelete(r *http.Request, actor *Actor, source, path string, changes []activitydb.FieldChange) {
RecordQuotaMutation(r, actor, activitydb.EventQuotaDelete, source, path, changes)
}
func RecordQuotaMutation(r *http.Request, actor *Actor, eventType activitydb.EventType, source, path string, changes []activitydb.FieldChange) {
RecordUser(r, actor, activitydb.Entry{
EventType: eventType,
Source: source,
Path: path,
Details: activitydb.Details{
Source: source,
Path: path,
Changes: changes,
},
})
}
func quotaFolderChanges(q quota.FolderQuota) []activitydb.FieldChange {
changes := []activitydb.FieldChange{
{Field: "limitBytes", To: strconv.FormatInt(q.LimitBytes, 10)},
{Field: "meter", To: q.Meter},
}
if q.UserID > 0 {
changes = append(changes, activitydb.FieldChange{
Field: "userId",
To: strconv.FormatUint(q.UserID, 10),
})
}
return changes
}
func QuotaFolderCreateChanges(q quota.FolderQuota) []activitydb.FieldChange {
return quotaFolderChanges(q)
}
func QuotaFolderDeleteChanges(q quota.FolderQuota) []activitydb.FieldChange {
return quotaFolderChanges(q)
}
func QuotaFolderUpdateChanges(before, after quota.FolderQuota) []activitydb.FieldChange {
var changes []activitydb.FieldChange
if before.LimitBytes != after.LimitBytes {
changes = append(changes, activitydb.FieldChange{
Field: "limitBytes",
From: strconv.FormatInt(before.LimitBytes, 10),
To: strconv.FormatInt(after.LimitBytes, 10),
})
}
if before.Meter != after.Meter {
changes = append(changes, activitydb.FieldChange{
Field: "meter",
From: before.Meter,
To: after.Meter,
})
}
if before.UserID != after.UserID {
changes = append(changes, activitydb.FieldChange{
Field: "userId",
From: strconv.FormatUint(before.UserID, 10),
To: strconv.FormatUint(after.UserID, 10),
})
}
return changes
}
func AccessRuleCreateChanges(allow bool, ruleCategory, value string) []activitydb.FieldChange {
ruleType := "deny"
if allow {
+96 -80
View File
@@ -1,11 +1,13 @@
package files
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@@ -23,6 +25,8 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
var reDuration = regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
var CheckPermissionsFunc = func(opts utils.FileOptions, user *users.User) (string, string, error) {
return checkPermissionsImpl(opts, user, svc())
@@ -46,7 +50,7 @@ func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileO
wg.Go(func() {
fullPath := response.RealPath + "/" + fileItem.Name
if isAudio {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, sharedFFmpegService); err != nil {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, false, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
}
} else {
@@ -389,7 +393,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, false, nil)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
@@ -427,10 +431,57 @@ func generateOfficeId(realPath string) string {
return key
}
// Parses raw LRC text into a slice of Lyric.
// Lines with [hh:mm:ss.xx] (timestamps) will be parsed to ms for synced lyrics
// The ones without timestamp are kept as unsynced (timestamp 0).
func parseLRC(raw string) []iteminfo.Lyric {
var lines []iteminfo.Lyric
scanner := bufio.NewScanner(strings.NewReader(raw))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
matches := reDuration.FindStringSubmatch(line)
if len(matches) == 6 {
// Hours default to 0 (if not present in the timestamps)
h := 0
if matches[1] != "" {
h, _ = strconv.Atoi(matches[1])
}
min, _ := strconv.Atoi(matches[2])
sec, _ := strconv.Atoi(matches[3])
msStr := matches[4]
// Normalise to 3 digits, some songs have more than 3 digits in the milliseconds
switch {
case len(msStr) < 3:
msStr += strings.Repeat("0", 3-len(msStr))
case len(msStr) > 3:
msStr = msStr[:3]
}
ms, _ := strconv.Atoi(msStr)
timestamp := int64((h*3600+min*60+sec)*1000 + ms)
text := strings.TrimSpace(matches[5])
lines = append(lines, iteminfo.Lyric{
Text: text,
Timestamp: timestamp,
})
} else {
// Line without a valid timestamp – treat as unsynced (timestamp 0)
lines = append(lines, iteminfo.Lyric{
Text: line,
Timestamp: 0,
})
}
}
return lines
}
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
// and optionally extracts duration using the ffmpeg service with concurrency control
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, ffmpegService *ffmpeg.FFmpegService) error {
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, getLyrics bool, ffmpegService *ffmpeg.FFmpegService) error {
file, err := os.Open(realPath)
if err != nil {
return err
@@ -467,7 +518,28 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
item.Metadata.Track = track
// Check if lyrics are available without extract them
item.Metadata.HasLyrics = hasLyricsSidecar(realPath) || m.Lyrics() != ""
if rawLyrics := m.Lyrics(); rawLyrics != "" {
item.Metadata.HasLyrics = true
} else {
// Check for sidecar .lrc file
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
if _, err := os.Stat(lrcPath); err == nil {
item.Metadata.HasLyrics = true
}
}
if getLyrics {
lyrics, err := ExtractLyrics(realPath)
if err != nil {
logger.Debugf("failed to extract lyrics for %s: %v", realPath, err)
} else if len(lyrics) > 0 {
item.Metadata.Lyrics = lyrics
}
}
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
// This respects concurrency limits and gracefully handles missing ffmpeg
@@ -500,91 +572,35 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
var lyricsSidecarExts = []string{".elrc", ".lrc", ".srt", ".vtt"}
func lyricsSidecar(dir, nameWithoutExt, ext string) (path string, ok bool) {
sidecarPath := filepath.Join(dir, nameWithoutExt+ext)
info, err := os.Lstat(sidecarPath)
if err != nil {
return "", false
}
if !info.Mode().IsRegular() {
return "", false
}
if info.Size() > 15*1024*1024 { // 15MB
return "", false
}
return sidecarPath, true
}
func findLyricsSidecar(realPath string) (data []byte, ext string, ok bool) {
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
nameWithoutExt := strings.TrimSuffix(base, filepath.Ext(base))
for _, ext = range lyricsSidecarExts {
sidecarPath, candOk := lyricsSidecar(dir, nameWithoutExt, ext)
if !candOk {
continue
}
data, err := os.ReadFile(sidecarPath)
if err != nil {
continue
}
if strings.TrimSpace(string(data)) == "" {
continue
}
return data, strings.TrimPrefix(ext, "."), true
}
return nil, "", false
}
func hasLyricsSidecar(realPath string) bool {
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
nameWithoutExt := strings.TrimSuffix(base, filepath.Ext(base))
for _, ext := range lyricsSidecarExts {
sidecarPath, ok := lyricsSidecar(dir, nameWithoutExt, ext)
if !ok {
continue
}
f, err := os.Open(sidecarPath)
if err != nil {
continue
}
buf := make([]byte, 4096)
for {
n, readErr := f.Read(buf)
if n > 0 && strings.TrimSpace(string(buf[:n])) != "" {
f.Close()
return true
}
if readErr != nil {
break
}
}
f.Close()
}
return false
}
// Returns raw lyrics from an audio file (from embedded tags or from sidecar files with the same name).
func ExtractLyrics(realPath string) (string, string, error) {
if data, ext, ok := findLyricsSidecar(realPath); ok {
return string(data), ext, nil
}
// Returns lyrics from an audio file (from embedded tags or from a .lrc file with the same name).
func ExtractLyrics(realPath string) ([]iteminfo.Lyric, error) {
file, err := os.Open(realPath)
if err != nil {
return "", "", err
return nil, err
}
defer file.Close()
m, err := tag.ReadFrom(file)
if err != nil {
return "", "", err
return nil, err
}
var lyrics []iteminfo.Lyric
if raw := m.Lyrics(); raw != "" {
return raw, "embedded", nil
lyrics = parseLRC(raw)
}
return "", "", nil
if len(lyrics) == 0 {
// Check for sidecar .lrc file
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
if data, err := os.ReadFile(lrcPath); err == nil {
lyrics = parseLRC(string(data))
}
}
return lyrics, nil
}
// extractVideoMetadata extracts duration and codec info from video files using ffprobe.
@@ -447,7 +447,6 @@ var types = map[string]string{
".vtt": "text/subtitle-vtt",
".ssa": "text/subtitle-ssa",
".lrc": "text/lyrics",
".elrc": "text/lyrics",
".sbv": "text/plain",
".ssi": "text/x-server-parsed-html",
".ssm": "application/streamingmedia",
@@ -21,13 +21,11 @@ func (d dirFS) Open(name string) (fs.File, error) {
// InitAssetFS initializes the asset filesystem for the application
// This should be called once during startup before http or preview services start
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool, devMode bool) {
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
if useEmbedded {
assetFs = embeddedAssets
} else if devMode {
// Dev mode: serve frontend source assets (templates, fonts, icons)
assetFs = dirFS{Dir: http.Dir("../frontend/public")}
} else {
// Dev mode: Serve files from internal/web/dist directory
assetFs = dirFS{Dir: http.Dir("internal/web/dist")}
}
}
+27 -55
View File
@@ -56,62 +56,48 @@ func unixModeToFileMode(u uint32) os.FileMode {
}
// 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.
// 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 {
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)
err := os.Rename(src, dst)
if err == nil {
return nil
}
return nil
}
func preserveFileMode(path string, mode os.FileMode) error {
return os.Chmod(path, mode.Perm())
// fallback
err = CopyFile(src, dst)
if err != nil {
logger.Errorf("CopyFile failed %v", err)
return err
}
go func() {
err = os.RemoveAll(src)
if err != nil {
logger.Errorf("os.Remove failed %v", err)
}
}()
return nil
}
// CopyFile copies a file or directory from source to dest and returns an error if any.
func CopyFile(source, dest string) error {
info, err := os.Lstat(source)
// Check if the source exists and whether it's a file or directory.
info, err := os.Stat(source)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return copySymlink(source, dest)
}
if info.IsDir() {
// If the source is a directory, copy it recursively.
return copyDirectory(source, dest)
}
// If the source is a file, copy the file.
return copySingleFile(source, dest)
}
func copySymlink(source, dest string) error {
target, err := os.Readlink(source)
if err != nil {
return err
}
destDir := filepath.Dir(dest)
if err = os.MkdirAll(destDir, EffectiveDirPerm()); err != nil {
return err
}
tmpDir, err := os.MkdirTemp(destDir, ".fb-link-*")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmpPath := filepath.Join(tmpDir, "link")
if err := os.Symlink(target, tmpPath); err != nil {
return err
}
return os.Rename(tmpPath, dest)
}
// copySingleFile handles copying a single file.
func copySingleFile(source, dest string) error {
// Get source file info to preserve permissions
@@ -175,7 +161,7 @@ func copyFileContents(dst io.Writer, src io.Reader) error {
// copyDirectory handles copying directories recursively.
func copyDirectory(source, dest string) error {
srcInfo, err := os.Lstat(source)
srcInfo, err := os.Stat(source)
if err != nil {
return err
}
@@ -184,9 +170,6 @@ func copyDirectory(source, dest string) error {
if err != nil {
return err
}
if err = preserveFileMode(dest, srcInfo.Mode()); err != nil {
logger.Debugf("Could not set directory permissions for %s: %v", dest, err)
}
// Read the contents of the source directory.
entries, err := os.ReadDir(source)
@@ -199,17 +182,6 @@ func copyDirectory(source, dest string) error {
srcPath := filepath.Join(source, entry.Name())
destPath := filepath.Join(dest, entry.Name())
if entry.Type()&os.ModeSymlink != 0 {
linkTarget := ""
linkTarget, err = os.Readlink(srcPath)
if err != nil {
return err
}
if err = os.Symlink(linkTarget, destPath); err != nil {
return err
}
continue
}
if entry.IsDir() {
// Recursively copy subdirectories.
err = copyDirectory(srcPath, destPath)
@@ -139,223 +139,6 @@ func TestCopyFilePreservesModTime(t *testing.T) {
}
}
// moveViaCrossDeviceFallback mirrors MoveFile's EXDEV fallback (CopyFile + RemoveAll).
func moveViaCrossDeviceFallback(t *testing.T, src, dst string) {
t.Helper()
if err := CopyFile(src, dst); err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(src); err != nil {
t.Fatal(err)
}
}
func TestMoveFileDoesNotFallbackOnNonCrossDeviceError(t *testing.T) {
srcDir := t.TempDir()
src := filepath.Join(srcDir, "file.txt")
if err := os.WriteFile(src, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
dst := filepath.Join(srcDir, "existing-dir")
if err := os.Mkdir(dst, 0o755); err != nil {
t.Fatal(err)
}
err := MoveFile(src, dst)
if err == nil {
t.Fatal("expected rename error")
}
if _, statErr := os.Stat(src); statErr != nil {
t.Fatalf("source file should remain after rename failure: %v", statErr)
}
entries, err := os.ReadDir(dst)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("destination directory should be unchanged, got %d entries", len(entries))
}
}
func TestMoveFileCrossDevicePreservesFilePermissions(t *testing.T) {
srcDir := t.TempDir()
dstDir := t.TempDir()
src := filepath.Join(srcDir, "secret.txt")
if err := os.WriteFile(src, []byte("data"), 0o640); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dstDir, "secret.txt")
moveViaCrossDeviceFallback(t, src, dst)
got, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if got.Mode().Perm() != 0o640 {
t.Fatalf("dst perm = %#o, want 0640", got.Mode().Perm())
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Fatalf("src should be removed, stat err = %v", err)
}
}
func TestMoveFileCrossDeviceMovesDirectory(t *testing.T) {
srcDir := t.TempDir()
dstParent := t.TempDir()
nested := filepath.Join(srcDir, "nested.txt")
if err := os.WriteFile(nested, []byte("nested"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(srcDir, 0o755); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dstParent, "moved-dir")
moveViaCrossDeviceFallback(t, srcDir, dst)
movedNested := filepath.Join(dst, "nested.txt")
if _, err := os.Stat(movedNested); err != nil {
t.Fatalf("expected nested file at destination: %v", err)
}
got, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if got.Mode().Perm() != 0o755 {
t.Fatalf("dst dir perm = %#o, want 0755", got.Mode().Perm())
}
if _, err := os.Stat(srcDir); !os.IsNotExist(err) {
t.Fatalf("source directory should be removed, stat err = %v", err)
}
}
func TestMoveFileCrossDevicePreservesSymlinkToFile(t *testing.T) {
targetDir := t.TempDir()
target := filepath.Join(targetDir, "target.txt")
if err := os.WriteFile(target, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
srcDir := t.TempDir()
dstDir := t.TempDir()
link := filepath.Join(srcDir, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dstDir, "link")
moveViaCrossDeviceFallback(t, link, dst)
gotTarget, err := os.Readlink(dst)
if err != nil {
t.Fatal(err)
}
if gotTarget != target {
t.Fatalf("link target = %q, want %q", gotTarget, target)
}
info, err := os.Lstat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("expected destination to remain a symlink")
}
if _, err := os.Stat(link); !os.IsNotExist(err) {
t.Fatalf("source symlink should be removed, stat err = %v", err)
}
}
func TestMoveFileCrossDevicePreservesSymlinkToDirectory(t *testing.T) {
targetDir := t.TempDir()
if err := os.Mkdir(filepath.Join(targetDir, "inside"), 0o755); err != nil {
t.Fatal(err)
}
srcDir := t.TempDir()
dstDir := t.TempDir()
link := filepath.Join(srcDir, "dirlink")
if err := os.Symlink(targetDir, link); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dstDir, "dirlink")
moveViaCrossDeviceFallback(t, link, dst)
gotTarget, err := os.Readlink(dst)
if err != nil {
t.Fatal(err)
}
if gotTarget != targetDir {
t.Fatalf("link target = %q, want %q", gotTarget, targetDir)
}
info, err := os.Lstat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("expected destination to remain a symlink")
}
}
func TestMoveFileCrossDeviceMovesDirectoryWithNestedSymlink(t *testing.T) {
target := filepath.Join(t.TempDir(), "target.txt")
if err := os.WriteFile(target, []byte("linked"), 0o644); err != nil {
t.Fatal(err)
}
srcDir := t.TempDir()
if err := os.Symlink(target, filepath.Join(srcDir, "nested-link")); err != nil {
t.Fatal(err)
}
dstParent := t.TempDir()
dst := filepath.Join(dstParent, "moved-dir")
moveViaCrossDeviceFallback(t, srcDir, dst)
movedLink := filepath.Join(dst, "nested-link")
gotTarget, err := os.Readlink(movedLink)
if err != nil {
t.Fatal(err)
}
if gotTarget != target {
t.Fatalf("nested link target = %q, want %q", gotTarget, target)
}
info, err := os.Lstat(movedLink)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("expected nested entry to remain a symlink")
}
}
func TestCopySymlinkReplacesExistingDestination(t *testing.T) {
target := filepath.Join(t.TempDir(), "target.txt")
if err := os.WriteFile(target, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
workDir := t.TempDir()
src := filepath.Join(workDir, "link")
if err := os.Symlink(target, src); err != nil {
t.Fatal(err)
}
dst := filepath.Join(workDir, "existing")
if err := os.WriteFile(dst, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
if err := copySymlink(src, dst); err != nil {
t.Fatal(err)
}
gotTarget, err := os.Readlink(dst)
if err != nil {
t.Fatal(err)
}
if gotTarget != target {
t.Fatalf("link target = %q, want %q", gotTarget, target)
}
info, err := os.Lstat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("expected destination to be a symlink")
}
}
func 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/")
}
@@ -1,17 +0,0 @@
//go:build !windows
package fileutils
import (
"errors"
"os"
"syscall"
)
func isCrossDeviceRenameError(err error) bool {
if errors.Is(err, syscall.EXDEV) {
return true
}
var linkErr *os.LinkError
return errors.As(err, &linkErr) && errors.Is(linkErr.Err, syscall.EXDEV)
}
@@ -1,18 +0,0 @@
//go:build windows
package fileutils
import (
"errors"
"os"
"syscall"
)
func isCrossDeviceRenameError(err error) bool {
const errNotSameDevice = syscall.Errno(17)
if errors.Is(err, errNotSameDevice) {
return true
}
var linkErr *os.LinkError
return errors.As(err, &linkErr) && errors.Is(linkErr.Err, errNotSameDevice)
}
-12
View File
@@ -1,12 +0,0 @@
package analytics
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
+8 -1
View File
@@ -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)
}
+1 -1
View File
@@ -14,5 +14,5 @@ type JSONAuth struct {
// Auth authenticates the user via a json in content body (legacy method for compatibility).
func (auther JSONAuth) Auth(r *http.Request, _ *users.Storage) (*users.User, error) {
return AuthenticatePassword(r, auther.ReCaptcha)
return AuthenticatePassword(r, auther.ReCaptcha, false)
}
-12
View File
@@ -1,12 +0,0 @@
package auth
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
+4 -1
View File
@@ -15,7 +15,10 @@ type NoAuth struct{}
// AuthenticateNoAuth authenticates as the configured admin user with no credentials required.
func AuthenticateNoAuth(r *http.Request, user *users.Storage) (*users.User, error) {
admin := settings.PasswordAdminUsername()
admin := settings.Config.Auth.AdminUsername
if admin == "" {
admin = "admin"
}
id, err := users.ResolveUsernameToID(admin)
if err != nil {
return nil, err
-31
View File
@@ -1,10 +1,8 @@
package auth
import (
"encoding/json"
"net/http"
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
@@ -40,32 +38,3 @@ func AuthenticateProxy(r *http.Request, usr *users.Storage, headerName string) (
func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
return AuthenticateProxy(r, usr, a.Header)
}
// ExtractGroupsFromHeader reads group/role values from a proxy header named by groupsClaim.
// Returns present=false when the header name is empty or the header is missing/blank.
func ExtractGroupsFromHeader(r *http.Request, headerName string) ([]string, bool) {
if headerName == "" {
return nil, false
}
headerVal := strings.TrimSpace(r.Header.Get(headerName))
if headerVal == "" {
return nil, false
}
if strings.HasPrefix(headerVal, "[") {
var groups []string
if err := json.Unmarshal([]byte(headerVal), &groups); err == nil {
return groups, true
}
}
groups := ExtractGroupsFromClaims(map[string]interface{}{headerName: headerVal}, headerName)
if len(groups) == 1 && strings.Contains(groups[0], ",") {
parts := strings.Split(groups[0], ",")
groups = make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
groups = append(groups, part)
}
}
}
return groups, true
}
@@ -1,81 +0,0 @@
package auth_test
import (
"net/http"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
)
func TestExtractGroupsFromHeader_SingleValue(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Cosmos-Role", "2")
groups, present := auth.ExtractGroupsFromHeader(req, "x-cosmos-role")
if !present {
t.Fatal("expected groups to be present")
}
if len(groups) != 1 || groups[0] != "2" {
t.Fatalf("groups = %#v, want [\"2\"]", groups)
}
}
func TestExtractGroupsFromHeader_CommaSeparated(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Groups", "1, 2")
groups, present := auth.ExtractGroupsFromHeader(req, "X-Groups")
if !present {
t.Fatal("expected groups to be present")
}
if len(groups) != 2 || groups[0] != "1" || groups[1] != "2" {
t.Fatalf("groups = %#v, want [\"1\", \"2\"]", groups)
}
}
func TestExtractGroupsFromHeader_JSONArray(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Groups", `["admin","users"]`)
groups, present := auth.ExtractGroupsFromHeader(req, "X-Groups")
if !present {
t.Fatal("expected groups to be present")
}
if len(groups) != 2 || groups[0] != "admin" || groups[1] != "users" {
t.Fatalf("groups = %#v, want [\"admin\", \"users\"]", groups)
}
}
func TestExtractGroupsFromHeader_Missing(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
groups, present := auth.ExtractGroupsFromHeader(req, "X-Groups")
if present {
t.Fatalf("expected groups to be absent, got %#v", groups)
}
}
func TestExtractGroupsFromHeader_EmptyHeaderName(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Groups", "admin")
groups, present := auth.ExtractGroupsFromHeader(req, "")
if present {
t.Fatalf("expected groups to be absent, got %#v", groups)
}
}
+7 -3
View File
@@ -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)
}
})
}
+45 -234
View File
@@ -1,7 +1,6 @@
package access
import (
"encoding/json"
"fmt"
"maps"
"path/filepath"
@@ -110,25 +109,12 @@ type Storage struct {
sqlStore SQLPersister // SQL store for persistence
}
// RuleKey identifies one access_rules row.
type RuleKey struct {
Source string
Path string
}
// RuleUpsert is an access_rules row to insert or replace.
type RuleUpsert struct {
RuleKey
Rule *AccessRule
}
// SQLPersister interface for SQL persistence operations
type SQLPersister interface {
SaveAccessRule(source, path string, rule *AccessRule) error
DeleteAccessRule(source, path string) error
SaveGroup(name string, members StringSet) error
DeleteGroup(name string) error
DeleteGroupWithRules(name string, save []RuleUpsert, remove []RuleKey) error
SaveRevokedToken(tokenHash string, revokedAt int64) error
PersistImmediateTokenRevocation(tokenHash string) error
PersistTokenRetirement(tokenHash string, revokedAt int64, pruned []string) error
@@ -150,35 +136,30 @@ func (s *Storage) Flush() error {
// persistGroupSQLNL upserts or deletes one groups row to match in-memory state.
// Caller must hold s.mux. If the group is absent from s.Groups, it is deleted from SQL.
func (s *Storage) persistGroupSQLNL(groupname string) error {
func (s *Storage) persistGroupSQLNL(groupname string) {
if s.sqlStore == nil {
return nil
return
}
members, ok := s.Groups[groupname]
if !ok {
if err := s.sqlStore.DeleteGroup(groupname); err != nil {
return fmt.Errorf("failed to delete group %q from sql: %w", groupname, err)
logger.Errorf("failed to delete group %q from sql: %v", groupname, err)
}
return nil
return
}
if err := s.sqlStore.SaveGroup(groupname, members); err != nil {
return fmt.Errorf("failed to save group %q: %w", groupname, err)
logger.Errorf("failed to save group %q: %v", groupname, err)
}
return nil
}
// ensureGroupExistsNL creates an empty in-memory group if missing and write-through persists it.
// Caller must hold s.mux.
func (s *Storage) ensureGroupExistsNL(groupname string) error {
func (s *Storage) ensureGroupExistsNL(groupname string) {
if _, ok := s.Groups[groupname]; ok {
return nil
return
}
s.Groups[groupname] = make(StringSet)
if err := s.persistGroupSQLNL(groupname); err != nil {
delete(s.Groups, groupname)
return err
}
return nil
s.persistGroupSQLNL(groupname)
}
// NewStorage creates a new Storage instance.
@@ -326,9 +307,7 @@ func (s *Storage) AllowUser(sourcePath string, indexPath utils.IndexPath, userna
func (s *Storage) DenyGroup(sourcePath string, indexPath utils.IndexPath, groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
if err := s.ensureGroupExistsNL(groupname); err != nil {
return err
}
s.ensureGroupExistsNL(groupname)
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Deny.Groups[groupname]; ok {
return errors.ErrExist
@@ -344,9 +323,7 @@ func (s *Storage) DenyGroup(sourcePath string, indexPath utils.IndexPath, groupn
func (s *Storage) AllowGroup(sourcePath string, indexPath utils.IndexPath, groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
if err := s.ensureGroupExistsNL(groupname); err != nil {
return err
}
s.ensureGroupExistsNL(groupname)
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Allow.Groups[groupname]; ok {
return errors.ErrExist
@@ -596,22 +573,14 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
func (s *Storage) AddUserToGroup(group, username string) error {
s.mux.Lock()
defer s.mux.Unlock()
createdGroup := false
if _, ok := s.Groups[group]; !ok {
s.Groups[group] = make(StringSet)
createdGroup = true
}
if _, ok := s.Groups[group][username]; ok {
return nil
}
s.Groups[group][username] = struct{}{}
if err := s.persistGroupSQLNL(group); err != nil {
delete(s.Groups[group], username)
if createdGroup {
delete(s.Groups, group)
}
return err
}
s.persistGroupSQLNL(group)
s.clearAllCaches()
return nil
}
@@ -628,103 +597,6 @@ func (s *Storage) GetAllGroups() []string {
return groups
}
// GetGroupMembers returns every group with its sorted member usernames.
func (s *Storage) GetGroupMembers() map[string][]string {
s.mux.RLock()
defer s.mux.RUnlock()
result := make(map[string][]string, len(s.Groups))
for group, members := range s.Groups {
names := make([]string, 0, len(members))
for name := range members {
names = append(names, name)
}
sort.Strings(names)
result[group] = names
}
return result
}
// SetGroupMembers creates the group if needed and replaces its membership.
func (s *Storage) SetGroupMembers(group string, usernames []string) error {
s.mux.Lock()
defer s.mux.Unlock()
members := make(StringSet, len(usernames))
for _, name := range usernames {
if name != "" {
members[name] = struct{}{}
}
}
previous, existed := s.Groups[group]
s.Groups[group] = members
if err := s.persistGroupSQLNL(group); err != nil {
// Keep memory and SQL consistent: undo the in-memory change.
if existed {
s.Groups[group] = previous
} else {
delete(s.Groups, group)
}
return err
}
s.clearAllCaches()
return nil
}
// DeleteGroup removes a group and every access rule entry that references it.
// The group row and all affected rule rows are written in one SQL transaction, so a
// failure stores nothing. On failure the in-memory groups and rules are restored and the
// error is returned. The lock is held throughout, so no other change can interleave.
func (s *Storage) DeleteGroup(group string) error {
s.mux.Lock()
defer s.mux.Unlock()
previousMembers, existed := s.Groups[group]
// Snapshot the rules so they can be restored if the transaction fails.
rulesSnapshot, err := json.Marshal(s.AllRules)
if err != nil {
return fmt.Errorf("failed to snapshot access rules: %w", err)
}
delete(s.Groups, group)
touched := s.removeAllRulesForGroupNL(group)
if !existed && len(touched) == 0 {
return nil
}
if err := s.persistGroupDeleteNL(group, touched); err != nil {
if existed {
s.Groups[group] = previousMembers
}
restored := make(SourceRuleMap)
if jsonErr := json.Unmarshal(rulesSnapshot, &restored); jsonErr != nil {
logger.Errorf("failed to restore access rules after group delete error: %v", jsonErr)
} else {
s.AllRules = restored
}
return err
}
s.clearAllCaches()
return nil
}
// persistGroupDeleteNL writes a group deletion and the resulting rule row changes to SQL
// in one transaction. Caller must hold s.mux.
func (s *Storage) persistGroupDeleteNL(group string, touched []RuleKey) error {
if s.sqlStore == nil {
return nil
}
var save []RuleUpsert
var remove []RuleKey
for _, key := range touched {
var rule *AccessRule
if bySrc, ok := s.AllRules[key.Source]; ok {
rule = bySrc[key.Path]
}
if accessRuleHasPayload(rule) {
save = append(save, RuleUpsert{RuleKey: key, Rule: rule})
} else {
remove = append(remove, key)
}
}
return s.sqlStore.DeleteGroupWithRules(group, save, remove)
}
// GetUserGroups returns all groups for a specific user.
func (s *Storage) GetUserGroups(username string) []string {
s.mux.RLock()
@@ -744,6 +616,7 @@ func (s *Storage) GetUserGroups(username string) []string {
func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
s.mux.Lock()
defer s.mux.Unlock()
affected := make(map[string]struct{})
// Create a set of new groups for efficient lookup
newGroupsSet := make(StringSet, len(newGroups))
@@ -754,62 +627,30 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
newGroupsSet[g] = struct{}{}
}
affected := make(map[string]struct{})
// Iterate over all existing groups to find the user's current memberships
for group, members := range s.Groups {
_, userIsInGroup := members[username]
_, groupIsInNewSet := newGroupsSet[group]
if userIsInGroup && !groupIsInNewSet {
affected[group] = struct{}{}
}
}
for group := range newGroupsSet {
if _, ok := s.Groups[group]; !ok {
affected[group] = struct{}{}
continue
}
if _, ok := s.Groups[group][username]; !ok {
affected[group] = struct{}{}
}
}
before := make(map[string]StringSet, len(affected))
for group := range affected {
before[group] = cloneStringSet(s.Groups[group])
}
for group, members := range s.Groups {
_, userIsInGroup := members[username]
_, groupIsInNewSet := newGroupsSet[group]
// If user is in a group that is not in their new set of groups, remove them.
if userIsInGroup && !groupIsInNewSet {
delete(s.Groups[group], username)
affected[group] = struct{}{}
}
}
// Add user to new groups
for group := range newGroupsSet {
if _, ok := s.Groups[group]; !ok {
s.Groups[group] = make(StringSet)
}
s.Groups[group][username] = struct{}{}
}
affectedGroups := slices.Sorted(maps.Keys(affected))
var persisted []string
for _, group := range affectedGroups {
if err := s.persistGroupSQLNL(group); err != nil {
for _, g := range persisted {
if restoreErr := s.restoreGroupSQLNL(g, before[g]); restoreErr != nil {
logger.Errorf("failed to restore group %q in sql after sync failure: %v", g, restoreErr)
}
}
for g, snap := range before {
if len(snap) == 0 {
delete(s.Groups, g)
} else {
s.Groups[g] = snap
}
}
return err
if _, ok := s.Groups[group][username]; !ok {
s.Groups[group][username] = struct{}{}
affected[group] = struct{}{}
}
persisted = append(persisted, group)
}
for group := range affected {
s.persistGroupSQLNL(group)
}
if len(affected) > 0 {
s.clearAllCaches()
@@ -817,31 +658,6 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
return nil
}
func (s *Storage) restoreGroupSQLNL(groupname string, members StringSet) error {
if s.sqlStore == nil {
return nil
}
if len(members) == 0 {
if err := s.sqlStore.DeleteGroup(groupname); err != nil {
return fmt.Errorf("failed to delete group %q from sql: %w", groupname, err)
}
return nil
}
if err := s.sqlStore.SaveGroup(groupname, members); err != nil {
return fmt.Errorf("failed to save group %q: %w", groupname, err)
}
return nil
}
func cloneStringSet(src StringSet) StringSet {
if len(src) == 0 {
return nil
}
dst := make(StringSet, len(src))
maps.Copy(dst, src)
return dst
}
// RemoveUserFromGroup removes a username from a group.
func (s *Storage) RemoveUserFromGroup(group, username string) error {
s.mux.Lock()
@@ -854,10 +670,7 @@ func (s *Storage) RemoveUserFromGroup(group, username string) error {
return nil
}
delete(members, username)
if err := s.persistGroupSQLNL(group); err != nil {
s.Groups[group][username] = struct{}{}
return err
}
s.persistGroupSQLNL(group)
s.clearAllCaches()
return nil
}
@@ -1055,46 +868,44 @@ func (s *Storage) RemoveAllRulesForUser(username string) error {
func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
touched := s.removeAllRulesForGroupNL(groupname)
if len(touched) > 0 {
s.clearAllCaches()
for _, key := range touched {
s.persistRuleSQLNL(key.Source, key.Path)
changed := false
dirty := make(map[string]map[string]struct{})
touch := func(sp, ip string) {
if dirty[sp] == nil {
dirty[sp] = make(map[string]struct{})
}
dirty[sp][ip] = struct{}{}
}
return nil
}
// removeAllRulesForGroupNL removes a group from every allow and deny list in memory and
// returns the rules it touched (rules left empty are deleted). It does not persist; the
// caller must hold s.mux and save the returned rules.
func (s *Storage) removeAllRulesForGroupNL(groupname string) []RuleKey {
var touched []RuleKey
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
touchedRule := false
if _, exists := rule.Allow.Groups[groupname]; exists {
delete(rule.Allow.Groups, groupname)
touchedRule = true
touch(sourcePath, indexPath)
changed = true
}
if _, exists := rule.Deny.Groups[groupname]; exists {
delete(rule.Deny.Groups, groupname)
touchedRule = true
touch(sourcePath, indexPath)
changed = true
}
// A rule left with only DenyAll must stay, otherwise removing the group would open the path.
if !accessRuleHasPayload(rule) {
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
touchedRule = true
}
if touchedRule {
touched = append(touched, RuleKey{Source: sourcePath, Path: indexPath})
touch(sourcePath, indexPath)
}
}
}
return touched
if changed {
s.clearAllCaches()
for sp, paths := range dirty {
for ip := range paths {
s.persistRuleSQLNL(sp, ip)
}
}
}
return nil
}
// GetRulesForUser returns all rules for a specific user for a given sourcePath.
@@ -1,7 +1,6 @@
package access_test
import (
stderrors "errors"
"os"
"path/filepath"
"strings"
@@ -1852,200 +1851,3 @@ func TestAddUserToGroup_WriteThrough(t *testing.T) {
t.Fatal("bob should be removed from editors in SQL")
}
}
func TestSetGroupMembers_ReplacesMembership(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("editors", []string{"alice", "bob", ""}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.SetGroupMembers("editors", []string{"bob", "carol"}); err != nil {
t.Fatalf("SetGroupMembers replace: %v", err)
}
got := s.GetGroupMembers()["editors"]
if len(got) != 2 || got[0] != "bob" || got[1] != "carol" {
t.Fatalf("unexpected members: %v", got)
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatalf("GetAllGroups: %v", err)
}
if _, ok := sqlGroups["editors"]["alice"]; ok {
t.Fatal("alice should have been removed in SQL")
}
if _, ok := sqlGroups["editors"]["carol"]; !ok {
t.Fatal("carol should be persisted in SQL")
}
}
func TestDeleteGroup_RemovesGroupAndRules(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup: %v", err)
}
if err := s.DeleteGroup("acme"); err != nil {
t.Fatalf("DeleteGroup: %v", err)
}
if _, ok := s.GetGroupMembers()["acme"]; ok {
t.Fatal("acme should be gone from memory")
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatalf("GetAllGroups: %v", err)
}
if _, ok := sqlGroups["acme"]; ok {
t.Fatal("acme should be gone from SQL")
}
if rules := s.GetRulesForGroup("mnt/storage", "acme"); len(rules) != 0 {
t.Fatalf("expected no rules for deleted group, got %v", rules)
}
// Deleting a missing group is a no-op.
if err := s.DeleteGroup("acme"); err != nil {
t.Fatalf("DeleteGroup missing: %v", err)
}
}
// failingGroupDeletePersister wraps a real store but fails the transactional group delete.
type failingGroupDeletePersister struct {
access.SQLPersister
}
func (failingGroupDeletePersister) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
return stderrors.New("simulated sql failure")
}
func TestDeleteGroup_FailureLeavesGroupAndRulesIntact(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup: %v", err)
}
if err := s.DenyGroup("mnt/storage", idxPath("/private"), "acme"); err != nil {
t.Fatalf("DenyGroup: %v", err)
}
rulesBefore := len(s.GetRulesForGroup("mnt/storage", "acme"))
if rulesBefore != 2 {
t.Fatalf("expected 2 rules before delete, got %d", rulesBefore)
}
s.SetSQLStore(failingGroupDeletePersister{sqlStore})
if err := s.DeleteGroup("acme"); err == nil {
t.Fatal("expected DeleteGroup to return the SQL error")
}
if got := s.GetGroupMembers()["acme"]; len(got) != 1 || got[0] != "alice" {
t.Fatalf("group should be restored after a failed delete, got %v", got)
}
if got := len(s.GetRulesForGroup("mnt/storage", "acme")); got != rulesBefore {
t.Fatalf("rules should be restored after a failed delete: want %d, got %d", rulesBefore, got)
}
// SQL was never changed.
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["acme"]; !ok {
t.Fatal("group row should still be in SQL")
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
if len(sqlRules["mnt/storage"]) != 2 {
t.Fatalf("rule rows should still be in SQL, got %v", sqlRules["mnt/storage"])
}
}
func TestDeleteGroup_RemovesGroupAndRuleRowsFromSQL(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup: %v", err)
}
if err := s.AllowUser("mnt/storage", idxPath("/tenant"), "alice"); err != nil {
t.Fatalf("AllowUser: %v", err)
}
if err := s.DeleteGroup("acme"); err != nil {
t.Fatalf("DeleteGroup: %v", err)
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["acme"]; ok {
t.Fatal("group row should be gone from SQL")
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
for _, rule := range sqlRules["mnt/storage"] {
if _, has := rule.Allow.Groups["acme"]; has {
t.Fatal("no stored rule should still reference the deleted group")
}
}
// The rule that also allowed a user must survive with only the group removed.
if len(sqlRules["mnt/storage"]) != 1 {
t.Fatalf("expected the user rule to remain, got %v", sqlRules["mnt/storage"])
}
}
func TestDeleteGroup_KeepsDenyAllRule(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
// "Deny everyone except group acme": a DenyAll rule whose only allow entry is the group.
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.DenyAll("mnt/storage", idxPath("/tenant")); err != nil {
t.Fatalf("DenyAll: %v", err)
}
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup: %v", err)
}
if !s.Permitted("mnt/storage", idxPath("/tenant"), "alice") {
t.Fatal("alice should be permitted through the group before it is deleted")
}
if err := s.DeleteGroup("acme"); err != nil {
t.Fatalf("DeleteGroup: %v", err)
}
// Deleting the group must not remove the deny-all rule and open the path to everyone.
if s.Permitted("mnt/storage", idxPath("/tenant"), "alice") || s.Permitted("mnt/storage", idxPath("/tenant"), "bob") {
t.Fatal("path must stay denied for everyone after the allowed group is deleted")
}
rules := s.GetRulesForGroup("mnt/storage", "acme")
if len(rules) != 0 {
t.Fatalf("no rule should reference the deleted group, got %v", rules)
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
stored := 0
for _, r := range sqlRules["mnt/storage"] {
if r.DenyAll {
stored++
}
}
if stored != 1 {
t.Fatalf("expected the deny-all rule to remain stored in SQL, found %d", stored)
}
}
@@ -1,174 +0,0 @@
package access_test
import (
"errors"
"maps"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
type stubGroupSQL struct {
saveErr error
deleteErr error
}
func (s *stubGroupSQL) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
func (s *stubGroupSQL) DeleteAccessRule(string, string) error { return nil }
func (s *stubGroupSQL) 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) SaveGroup(name string, members access.StringSet) error {
if s.groups == nil {
s.groups = make(map[string]access.StringSet)
}
s.saveCalls++
if s.failOn > 0 && s.saveCalls == s.failOn {
return errors.New("save failed")
}
s.groups[name] = cloneStringSet(members)
return nil
}
func (s *recordingGroupSQL) DeleteGroup(name string) error {
if s.groups == nil {
s.groups = make(map[string]access.StringSet)
}
delete(s.groups, name)
return nil
}
func (s *recordingGroupSQL) DeleteGroupWithRules(name string, _ []access.RuleUpsert, _ []access.RuleKey) error {
return s.DeleteGroup(name)
}
func (s *recordingGroupSQL) SaveRevokedToken(string, int64) error { return nil }
func (s *recordingGroupSQL) PersistImmediateTokenRevocation(string) error { return nil }
func (s *recordingGroupSQL) PersistTokenRetirement(string, int64, []string) error { return nil }
func (s *recordingGroupSQL) DeleteRevokedToken(string) error { return nil }
func (s *recordingGroupSQL) SaveHashedToken(string, uint64, bool, int64) error { return nil }
func (s *recordingGroupSQL) DeleteHashedToken(string) error { return nil }
func (s *recordingGroupSQL) DeleteHashedTokensByUserID(uint64) error { return nil }
func cloneStringSet(src access.StringSet) access.StringSet {
if len(src) == 0 {
return nil
}
dst := make(access.StringSet, len(src))
maps.Copy(dst, src)
return dst
}
func TestAddUserToGroup_RollsBackOnSQLFailure(t *testing.T) {
userStore := users.NewStorage(nil)
store := access.NewStorage(userStore)
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
err := store.AddUserToGroup("editors", "alice")
if err == nil {
t.Fatal("expected SQL save error")
}
groups := store.GetUserGroups("alice")
if len(groups) != 0 {
t.Fatalf("expected in-memory rollback, alice groups = %#v", groups)
}
}
func TestRemoveUserFromGroup_RollsBackOnSQLFailure(t *testing.T) {
userStore := users.NewStorage(nil)
store := access.NewStorage(userStore)
if err := store.AddUserToGroup("editors", "alice"); err != nil {
t.Fatalf("seed group: %v", err)
}
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
err := store.RemoveUserFromGroup("editors", "alice")
if err == nil {
t.Fatal("expected SQL save error")
}
groups := store.GetUserGroups("alice")
if len(groups) != 1 || groups[0] != "editors" {
t.Fatalf("expected in-memory rollback, alice groups = %#v", groups)
}
}
func TestAllowGroup_ReturnsSQLSaveError(t *testing.T) {
setupTestSources()
store := access.NewStorage(users.NewStorage(nil))
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
err := store.AllowGroup("mnt/storage", idxPath("/tenant"), "acme")
if err == nil {
t.Fatal("expected SQL save error from AllowGroup")
}
}
func TestEnsureGroupExistsNL_RollsBackOnSQLFailure(t *testing.T) {
setupTestSources()
store := access.NewStorage(users.NewStorage(nil))
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
err := store.AllowGroup("mnt/storage", idxPath("/tenant"), "newgroup")
if err == nil {
t.Fatal("expected SQL save error")
}
if groups := store.GetAllGroups(); len(groups) != 0 {
t.Fatalf("expected no in-memory group after failed ensure, got %#v", groups)
}
}
func TestSyncUserGroups_RollsBackOnPartialPersistFailure(t *testing.T) {
userStore := users.NewStorage(nil)
store := access.NewStorage(userStore)
store.Groups["groupA"] = access.StringSet{"alice": {}}
rec := &recordingGroupSQL{
failOn: 2,
groups: map[string]access.StringSet{
"groupA": {"alice": {}},
},
}
store.SetSQLStore(rec)
err := store.SyncUserGroups("alice", []string{"groupB"})
if err == nil {
t.Fatal("expected partial persist failure")
}
groups := store.GetUserGroups("alice")
if len(groups) != 1 || groups[0] != "groupA" {
t.Fatalf("expected in-memory rollback to groupA only, got %#v", groups)
}
if _, ok := store.Groups["groupB"]; ok {
t.Fatal("groupB should not remain in memory after rollback")
}
if rec.saveCalls != 3 {
t.Fatalf("saveCalls = %d, want 3 (groupA persist, groupB fail, groupA restore)", rec.saveCalls)
}
if _, ok := rec.groups["groupA"]["alice"]; !ok {
t.Fatalf("expected SQL rollback for groupA, got %#v", rec.groups["groupA"])
}
if _, ok := rec.groups["groupB"]; ok {
t.Fatalf("expected SQL groupB to be absent after rollback, got %#v", rec.groups["groupB"])
}
}
@@ -1,12 +0,0 @@
package access
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
@@ -16,19 +16,18 @@ func (failingRevokePersister) SaveAccessRule(string, string, *access.AccessRule)
func (failingRevokePersister) DeleteAccessRule(string, string) error { return nil }
func (failingRevokePersister) SaveGroup(string, access.StringSet) error { return nil }
func (failingRevokePersister) DeleteGroup(string) error { return nil }
func (failingRevokePersister) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
return nil
}
func (failingRevokePersister) SaveRevokedToken(string, int64) error { return nil }
func (failingRevokePersister) SaveRevokedToken(string, int64) error { return nil }
func (failingRevokePersister) PersistImmediateTokenRevocation(string) error {
return errors.New("simulated revocation persistence failure")
}
func (failingRevokePersister) PersistTokenRetirement(string, int64, []string) error {
return errors.New("simulated retirement persistence failure")
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) 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) {
@@ -75,24 +74,6 @@ 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)
@@ -24,9 +24,6 @@ const (
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"
@@ -57,9 +54,6 @@ var AllEventTypes = []EventType{
EventAccessUpdate,
EventAccessCreate,
EventAccessDelete,
EventQuotaCreate,
EventQuotaUpdate,
EventQuotaDelete,
EventLogin,
EventLogout,
EventSignup,
@@ -90,13 +84,6 @@ var AccessEventTypes = []EventType{
EventAccessDelete,
}
// QuotaEventTypes are folder quota mutations (scope=quotas).
var QuotaEventTypes = []EventType{
EventQuotaCreate,
EventQuotaUpdate,
EventQuotaDelete,
}
// ShareEventTypes are share lifecycle events (scope=shares). Share downloads are
// EventDownload rows with a non-empty details.shareHash.
var ShareEventTypes = []EventType{
@@ -121,15 +108,13 @@ func ResolveScopeEventTypes(scope string, explicit []EventType) ([]EventType, er
allowed = FileEventTypes
case "access":
allowed = AccessEventTypes
case "quotas":
allowed = QuotaEventTypes
case "shares":
if len(explicit) == 0 {
return nil, nil
}
allowed = ShareScopeEventTypes
default:
return nil, fmt.Errorf("scope must be all, files, access, quotas, or shares")
return nil, fmt.Errorf("scope must be all, files, access, or shares")
}
if len(explicit) == 0 {
return allowed, nil
@@ -159,7 +144,6 @@ func (e EventType) Valid() bool {
EventArchive, EventUnarchive,
EventShareCreate, EventShareUpdate, EventShareDelete,
EventUserCreate, EventUserUpdate, EventUserDelete, EventAccessUpdate, EventAccessCreate, EventAccessDelete,
EventQuotaCreate, EventQuotaUpdate, EventQuotaDelete,
EventLogin, EventLogout, EventSignup,
EventPasskeyRegister, EventPasskeyDelete,
EventTokenCreate, EventTokenDelete,
@@ -44,18 +44,6 @@ func TestResolveScopeEventTypes(t *testing.T) {
t.Fatalf("access default missing %q in %v", et, got)
}
}
got, err = ResolveScopeEventTypes("quotas", nil)
if err != nil {
t.Fatal(err)
}
if len(got) != len(QuotaEventTypes) {
t.Fatalf("quotas default: got %d types, want %d", len(got), len(QuotaEventTypes))
}
for _, et := range QuotaEventTypes {
if !containsEventType(got, et) {
t.Fatalf("quotas default missing %q in %v", et, got)
}
}
}
func containsEventType(list []EventType, target EventType) bool {
@@ -21,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"`
-91
View File
@@ -1,91 +0,0 @@
package quota
import (
"fmt"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
)
// MeasurementStatusReady means enforcement uses the configured meter.
const MeasurementStatusReady = "ready"
// MeasurementStatusAccountedFallback means index meter configured but accounted counter used.
const MeasurementStatusAccountedFallback = "accounted_fallback"
// IsIndexMeter reports whether meter counts usage from the search index.
func IsIndexMeter(meter string) bool {
return meter == MeterIndexSize || meter == MeterIndexScope
}
// IndexingEnabled reports whether folder sizes are available from the index on this source.
func IndexingEnabled(sourceName string) bool {
idx := indexing.GetIndex(sourceName)
if idx == nil {
return false
}
return !idx.Config.ResolvedRules.IndexingDisabled
}
// IndexSizeAvailable reports whether GetFolderSize succeeds for the quota root path.
func IndexSizeAvailable(sourceName, indexPath string) bool {
idx := indexing.GetIndex(sourceName)
if idx == nil || idx.Config.ResolvedRules.IndexingDisabled {
return false
}
path := normalizeMeterPath(indexPath)
_, ok := idx.GetFolderSizeForIndexPath(path)
return ok
}
// EffectiveMeter resolves runtime meter: accounted when indexing off, configured index when size known, else accounted fallback.
func EffectiveMeter(configuredMeter, sourceName, quotaRootPath string) string {
if configuredMeter == MeterAccounted || !IsIndexMeter(configuredMeter) {
return MeterAccounted
}
if !IndexingEnabled(sourceName) {
return MeterAccounted
}
if IndexSizeAvailable(sourceName, quotaRootPath) {
return configuredMeter
}
return MeterAccounted
}
// MeasurementStatus compares configured vs effective meter for API snapshots.
func MeasurementStatus(configuredMeter, effectiveMeter string) string {
if IsIndexMeter(configuredMeter) && effectiveMeter == MeterAccounted {
return MeasurementStatusAccountedFallback
}
return MeasurementStatusReady
}
// ValidateConfiguredMeter rejects index meters when indexing is disabled on the source.
func ValidateConfiguredMeter(sourceName, meter string) error {
meter = strings.TrimSpace(meter)
if meter == "" {
return nil
}
if meter == MeterAccounted {
return nil
}
if IsIndexMeter(meter) {
if !IndexingEnabled(sourceName) {
return fmt.Errorf("index meter not allowed when indexing is disabled on source %q", sourceName)
}
return nil
}
return fmt.Errorf("invalid quota meter %q", meter)
}
func normalizeMeterPath(path string) string {
path = strings.TrimSpace(path)
path = strings.TrimSuffix(path, "/")
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
@@ -1,116 +0,0 @@
package quota_test
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupMeterTestSource(t *testing.T, name, path string, indexingDisabled bool) {
t.Helper()
prevSourceMap := settings.Config.Server.SourceMap
prevNameToSource := settings.Config.Server.NameToSource
t.Cleanup(func() {
indexing.ClearTestIndices()
settings.Config.Server.SourceMap = prevSourceMap
settings.Config.Server.NameToSource = prevNameToSource
})
indexing.SetTestIndex(name, path)
idx := indexing.GetIndex(name)
if idx == nil {
t.Fatal("expected test index")
}
idx.Config.ResolvedRules.IndexingDisabled = indexingDisabled
settings.Config.Server.SourceMap = map[string]*settings.Source{
path: {Path: path, Name: name},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
name: settings.Config.Server.SourceMap[path],
}
}
func TestValidateConfiguredMeter_IndexingDisabled(t *testing.T) {
root := t.TempDir()
name := "meter-src"
setupMeterTestSource(t, name, root, true)
if err := quota.ValidateConfiguredMeter(name, quota.MeterIndexSize); err == nil {
t.Fatal("expected error for index_size when indexing disabled")
}
if err := quota.ValidateConfiguredMeter(name, quota.MeterIndexScope); err == nil {
t.Fatal("expected error for index_scope when indexing disabled")
}
if err := quota.ValidateConfiguredMeter(name, quota.MeterAccounted); err != nil {
t.Fatalf("accounted should be allowed: %v", err)
}
}
func TestEffectiveMeter_IndexingDisabledForcesAccounted(t *testing.T) {
root := t.TempDir()
name := "meter-src2"
setupMeterTestSource(t, name, root, true)
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/")
if got != quota.MeterAccounted {
t.Fatalf("expected accounted, got %q", got)
}
}
func TestEffectiveMeter_ExplicitAccountedWhenIndexOn(t *testing.T) {
root := t.TempDir()
name := "meter-src3"
setupMeterTestSource(t, name, root, false)
idx := indexing.GetIndex(name)
idx.SetFolderSize("/", 1024)
got := quota.EffectiveMeter(quota.MeterAccounted, name, "/")
if got != quota.MeterAccounted {
t.Fatalf("expected accounted, got %q", got)
}
}
func TestEffectiveMeter_IndexWhenSizeAvailable(t *testing.T) {
root := t.TempDir()
name := "meter-src4"
setupMeterTestSource(t, name, root, false)
idx := indexing.GetIndex(name)
idx.SetFolderSize("/", 2048)
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/")
if got != quota.MeterIndexSize {
t.Fatalf("expected index_size, got %q", got)
}
status := quota.MeasurementStatus(quota.MeterIndexSize, got)
if status != quota.MeasurementStatusReady {
t.Fatalf("expected ready, got %q", status)
}
}
func TestEffectiveMeter_IndexPathTrailingSlash(t *testing.T) {
root := t.TempDir()
name := "meter-src6"
setupMeterTestSource(t, name, root, false)
idx := indexing.GetIndex(name)
idx.SetFolderSize("/projects/", 4096)
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/projects")
if got != quota.MeterIndexSize {
t.Fatalf("expected index_size for path without trailing slash, got %q", got)
}
}
func TestEffectiveMeter_RuntimeFallback(t *testing.T) {
root := t.TempDir()
name := "meter-src5"
setupMeterTestSource(t, name, root, false)
got := quota.EffectiveMeter(quota.MeterIndexScope, name, "/missing")
if got != quota.MeterAccounted {
t.Fatalf("expected accounted fallback, got %q", got)
}
status := quota.MeasurementStatus(quota.MeterIndexScope, got)
if status != quota.MeasurementStatusAccountedFallback {
t.Fatalf("expected accounted_fallback, got %q", status)
}
}
-43
View File
@@ -1,43 +0,0 @@
package quota
// Meter names for scope and folder quotas.
const (
MeterIndexScope = "index_scope"
MeterIndexSize = "index_size"
MeterAccounted = "accounted"
)
// FolderQuota is a path-bound storage cap with configurable meter.
type FolderQuota struct {
ID string `json:"id"`
Source string `json:"source"` // backend source path
Path string `json:"path"` // index path within source
UserID uint64 `json:"userId,omitempty"` // 0 = all users
LimitBytes int64 `json:"limitBytes"`
Meter string `json:"meter,omitempty"` // index_size | index_scope (indexed size) | accounted (tracked usage)
}
// Counter holds accounted usage for a quota_id.
type Counter struct {
QuotaID string `json:"quotaId"`
UsedBytes int64 `json:"usedBytes"`
ReservedBytes int64 `json:"reservedBytes"`
}
// Snapshot is a read model for API and admission checks.
type Snapshot struct {
QuotaID string `json:"quotaId"`
Kind string `json:"quotaKind"` // folder, scope, share
LimitBytes int64 `json:"limitBytes"`
UsedBytes int64 `json:"usedBytes"`
ReservedBytes int64 `json:"reservedBytes"`
Meter string `json:"meter,omitempty"` // effective meter (backward compatible)
ConfiguredMeter string `json:"configuredMeter,omitempty"`
EffectiveMeter string `json:"effectiveMeter,omitempty"`
MeasurementStatus string `json:"measurementStatus,omitempty"` // ready, accounted_fallback
}
// ShareQuotaID returns the counter key for a share quota.
func ShareQuotaID(hash string) string {
return "share:" + hash
}
+2 -19
View File
@@ -48,7 +48,6 @@ type ShareLimits struct {
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles,omitempty"`
DownloadsLimit int `json:"downloadsLimit,omitempty"`
QuotaLimitBytes int64 `json:"quotaLimitBytes,omitempty"`
HideFileExt string `json:"hideFileExt,omitempty"` // show hidden files based on extensions in shares
Banner string `json:"banner,omitempty"`
SourceName string `json:"source,omitempty"` // source display name for API; backend path is Share.SourcePath
@@ -86,11 +85,8 @@ type ShareEditable struct {
type ShareFrontend struct {
ShareEditable
ShareColumns
Username string `json:"username,omitempty"`
PathExists bool `json:"pathExists,omitempty"`
QuotaUsedBytes int64 `json:"quotaUsedBytes,omitempty"`
QuotaReservedBytes int64 `json:"quotaReservedBytes,omitempty"`
QuotaAvailableBytes int64 `json:"quotaAvailableBytes,omitempty"`
Username string `json:"username,omitempty"`
PathExists bool `json:"pathExists,omitempty"`
}
// SharePostBody is POST /api/share JSON. Plaintext password is hashed to Share.PasswordHash before persist.
@@ -102,19 +98,6 @@ type SharePostBody struct {
Path string `json:"path,omitempty"`
}
// EditableFromShare returns the client-editable subset of a persisted share.
// ShareExpiryInput is omitted because expiration is stored in ShareColumns.Expire;
// callers merging partial updates must preserve Expire when expires/unit are absent.
func EditableFromShare(s *Share) ShareEditable {
if s == nil {
return ShareEditable{}
}
return ShareEditable{
FrontendShareInfo: s.FrontendShareInfo,
ShareLimits: s.ShareLimits,
}
}
// ApplyPostBodyUpdate copies client-editable fields onto link.
// Caller must preserve path, sourcePath, pinnedItems, version, download counters, and secrets.
func ApplyPostBodyUpdate(link *Share, req *SharePostBody, expire int64) {
+5 -11
View File
@@ -4,13 +4,12 @@ import (
stderrors "errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/crud"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -137,23 +136,18 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
return 0, err
}
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
newPath = utils.AddTrailingSlashIfNotExists(newPath)
updated := 0
for _, l := range links {
if l == nil || l.SourcePath != oldSource {
continue
}
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
pos := strings.Index(l.Path, oldPath)
if pos < 0 {
rewrittenPath, matches := utils.RewritePathPrefix(l.Path, oldPath, newPath)
if !matches {
continue
}
l.SourcePath = newSource
l.Path = newPath
l.Path = rewrittenPath
if err := s.back.Save(l); err != nil {
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
-37
View File
@@ -101,43 +101,6 @@ 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 = ?`
@@ -1,98 +0,0 @@
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")
}
}
@@ -1,12 +0,0 @@
package sqldb
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
@@ -124,28 +124,6 @@ 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)
@@ -206,9 +184,6 @@ 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
}
@@ -234,48 +209,6 @@ 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 {
-221
View File
@@ -1,221 +0,0 @@
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()
}
+7 -23
View File
@@ -32,34 +32,18 @@ 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)
}
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)
_, err = s.db.Exec(query, key, valueJSON)
if err != nil {
return fmt.Errorf("failed to save setting: %w", err)
}
return nil
}
// DeleteSetting deletes a setting by key
@@ -64,7 +64,6 @@ func APIScopesToBackend(apiScopes []FrontendScope) ([]BackendScope, error) {
Path: source.Path,
Scope: scope.Scope,
Permissions: frontendScopePermissions(scope),
Quota: scope.Quota,
})
}
return newScopes, nil
@@ -87,7 +86,6 @@ func (u *User) GetFrontendScopes() []FrontendScope {
Name: source.Name,
Scope: scope.Scope,
Permissions: &perms,
Quota: scope.Quota,
})
}
return newScopes
@@ -11,8 +11,14 @@ const (
SidebarLinkSourceAlt SidebarLinkCategory = "source-alt"
SidebarLinkSourceHybrid SidebarLinkCategory = "source-hybrid"
SidebarLinkSourceHybrid2 SidebarLinkCategory = "source-hybrid-2"
SidebarLinkTool SidebarLinkCategory = "tool"
SidebarLinkCustom SidebarLinkCategory = "custom"
// "-root" variants display the root-filesystem-only disk usage (no nested
// mount aggregation) instead of the aggregated multi-mount totals.
SidebarLinkSourceRoot SidebarLinkCategory = "source-root"
SidebarLinkSourceAltRoot SidebarLinkCategory = "source-alt-root"
SidebarLinkSourceHybridRoot SidebarLinkCategory = "source-hybrid-root"
SidebarLinkSourceHybrid2Root SidebarLinkCategory = "source-hybrid-2-root"
SidebarLinkTool SidebarLinkCategory = "tool"
SidebarLinkCustom SidebarLinkCategory = "custom"
)
// NormalizeSidebarLinkCategory returns a known category string, preserving source-* variants.
@@ -23,7 +29,10 @@ func NormalizeSidebarLinkCategory(category string) string {
}
switch SidebarLinkCategory(c) {
case SidebarLinkSource, SidebarLinkSourceMinimal, SidebarLinkSourceAlt,
SidebarLinkSourceHybrid, SidebarLinkSourceHybrid2, SidebarLinkTool, SidebarLinkCustom:
SidebarLinkSourceHybrid, SidebarLinkSourceHybrid2,
SidebarLinkSourceRoot, SidebarLinkSourceAltRoot,
SidebarLinkSourceHybridRoot, SidebarLinkSourceHybrid2Root,
SidebarLinkTool, SidebarLinkCustom:
return c
}
if strings.HasPrefix(c, "source") {
@@ -36,3 +45,14 @@ func NormalizeSidebarLinkCategory(category string) string {
func IsSourceSidebarCategory(category string) bool {
return strings.HasPrefix(NormalizeSidebarLinkCategory(category), "source")
}
// SidebarLinkDiskScopeRoot reports whether the category shows the
// root-filesystem-only disk usage rather than the aggregated nested-mount usage.
func SidebarLinkDiskScopeRoot(category string) bool {
return strings.HasSuffix(NormalizeSidebarLinkCategory(category), "-root")
}
// BaseSidebarLinkCategory strips the root-only "-root" suffix from a category.
func BaseSidebarLinkCategory(category string) string {
return strings.TrimSuffix(NormalizeSidebarLinkCategory(category), "-root")
}
@@ -24,7 +24,11 @@ func HasAnyGlobalPermission(p Permissions) bool {
}
// IsMinimalApiToken reports whether the token inherits the owner's globals at auth time
// rather than embedding its own global permission caps in the JWT.
// rather than applying stored global permission caps.
func IsMinimalApiToken(t AuthToken) bool {
if t.Minimal != nil {
return *t.Minimal
}
// Legacy tokens lack minimal; empty stored global caps implied minimal (WebDAV-style).
return !HasAnyGlobalPermission(t.Permissions)
}
@@ -45,6 +45,14 @@ func TestIsMinimalApiToken(t *testing.T) {
t.Fatal("custom token with global caps should not be minimal")
}
if !IsMinimalApiToken(AuthToken{Permissions: Permissions{Modify: true}}) {
t.Fatal("token without global caps should be minimal")
t.Fatal("legacy token without global caps should be minimal")
}
minimal := true
if !IsMinimalApiToken(AuthToken{Minimal: &minimal}) {
t.Fatal("explicit minimal token should be minimal")
}
customized := false
if IsMinimalApiToken(AuthToken{Minimal: &customized}) {
t.Fatal("explicit customized token with empty caps should not be minimal")
}
}
@@ -1,164 +0,0 @@
package users
import (
"encoding/json"
"testing"
)
func assertBoolMapEntries(t *testing.T, name string, got, want map[string]bool) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s length = %d, want %d (got=%v want=%v)", name, len(got), len(want), got, want)
}
for key, wantVal := range want {
gotVal, ok := got[key]
if !ok {
t.Fatalf("%s missing key %q", name, key)
}
if gotVal != wantVal {
t.Fatalf("%s[%q] = %v, want %v", name, key, gotVal, wantVal)
}
}
}
func TestFrontendUserEffectiveToolAccessJSON(t *testing.T) {
t.Parallel()
u := FrontendUser{
Username: "alice",
EffectiveToolAccess: map[string]bool{
"sizeViewer": true,
"fileWatcher": false,
"activityViewer": true,
"duplicateFinder": false,
},
}
encoded, err := json.Marshal(u)
if err != nil {
t.Fatal(err)
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(encoded, &payload); err != nil {
t.Fatal(err)
}
if _, ok := payload["effectiveToolAccess"]; !ok {
t.Fatalf("expected effectiveToolAccess key in JSON, got keys: %v", mapKeys(payload))
}
var decoded FrontendUser
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
assertBoolMapEntries(t, "effectiveToolAccess", decoded.EffectiveToolAccess, u.EffectiveToolAccess)
}
func TestToolAccessMapRoundTripValidIDs(t *testing.T) {
t.Parallel()
original := ToolAccessMap{
ToolSizeViewer: true,
ToolFileWatcher: false,
ToolActivityViewer: true,
ToolDuplicateFinder: false,
ToolAdvancedSearch: true,
ToolMaterialIconPicker: false,
}
encoded, err := json.Marshal(original)
if err != nil {
t.Fatal(err)
}
var payload map[string]bool
if err := json.Unmarshal(encoded, &payload); err != nil {
t.Fatal(err)
}
if len(payload) != len(original) {
t.Fatalf("toolAccess JSON length = %d, want %d", len(payload), len(original))
}
var decoded ToolAccessMap
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if len(decoded) != len(original) {
t.Fatalf("toolAccess length = %d, want %d", len(decoded), len(original))
}
for id, want := range original {
got, ok := decoded[id]
if !ok {
t.Fatalf("toolAccess missing key %q", id)
}
if got != want {
t.Fatalf("toolAccess[%q] = %v, want %v", id, got, want)
}
}
}
func TestToolAccessMapRejectsUnknownToolID(t *testing.T) {
t.Parallel()
var m ToolAccessMap
err := json.Unmarshal([]byte(`{"sizeViewer":true,"notATool":false}`), &m)
if err == nil {
t.Fatal("expected unknown tool id to be rejected")
}
}
func TestUserToolAccessJSONRoundTrip(t *testing.T) {
t.Parallel()
wantToolAccess := ToolAccessMap{
ToolSizeViewer: true,
ToolFileWatcher: false,
}
u := User{
FrontendUser: FrontendUser{
Username: "bob",
ToolAccess: wantToolAccess,
},
}
encoded, err := json.Marshal(u)
if err != nil {
t.Fatal(err)
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(encoded, &payload); err != nil {
t.Fatal(err)
}
if _, ok := payload["toolAccess"]; !ok {
t.Fatalf("expected toolAccess key in JSON, got keys: %v", mapKeys(payload))
}
var decoded User
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if len(decoded.ToolAccess) != len(wantToolAccess) {
t.Fatalf("toolAccess length = %d, want %d", len(decoded.ToolAccess), len(wantToolAccess))
}
for id, want := range wantToolAccess {
got, ok := decoded.ToolAccess[id]
if !ok {
t.Fatalf("toolAccess missing key %q", id)
}
if got != want {
t.Fatalf("toolAccess[%q] = %v, want %v", id, got, want)
}
}
}
func mapKeys(m map[string]json.RawMessage) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
return keys
}
@@ -1,91 +0,0 @@
package users
import (
"encoding/json"
"fmt"
)
// ToolID identifies a built-in application tool (matches frontend route segments).
type ToolID string
const (
ToolSizeViewer ToolID = "sizeViewer"
ToolDuplicateFinder ToolID = "duplicateFinder"
ToolAdvancedSearch ToolID = "advancedSearch"
ToolMaterialIconPicker ToolID = "materialIconPicker"
ToolFileWatcher ToolID = "fileWatcher"
ToolActivityViewer ToolID = "activityViewer"
)
// AllToolIDs lists every defined tool in stable catalog order.
var AllToolIDs = []ToolID{
ToolSizeViewer,
ToolDuplicateFinder,
ToolAdvancedSearch,
ToolMaterialIconPicker,
ToolFileWatcher,
ToolActivityViewer,
}
// Valid reports whether id is a known tool constant.
func (id ToolID) Valid() bool {
switch id {
case ToolSizeViewer, ToolDuplicateFinder, ToolAdvancedSearch,
ToolMaterialIconPicker, ToolFileWatcher, ToolActivityViewer:
return true
default:
return false
}
}
// ParseToolID parses s into a ToolID when it names a catalog tool.
func ParseToolID(s string) (ToolID, error) {
id := ToolID(s)
if !id.Valid() {
return "", fmt.Errorf("unknown tool id %q", s)
}
return id, nil
}
// ToolAccessMap stores per-user tool grants keyed by ToolID.
// JSON uses string keys for API compatibility.
type ToolAccessMap map[ToolID]bool
// MarshalJSON encodes the map with string keys.
func (m ToolAccessMap) MarshalJSON() ([]byte, error) {
if m == nil {
return []byte("null"), nil
}
out := make(map[string]bool, len(m))
for id, granted := range m {
if id.Valid() {
out[string(id)] = granted
}
}
return json.Marshal(out)
}
// UnmarshalJSON decodes string-keyed JSON into typed ToolID keys.
func (m *ToolAccessMap) UnmarshalJSON(data []byte) error {
if m == nil {
return fmt.Errorf("ToolAccessMap: UnmarshalJSON on nil pointer")
}
if string(data) == "null" {
*m = nil
return nil
}
var raw map[string]bool
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
out := make(ToolAccessMap, len(raw))
for key, granted := range raw {
id, err := ParseToolID(key)
if err != nil {
return err
}
out[id] = granted
}
*m = out
return nil
}
@@ -21,3 +21,16 @@ func TestValidateUsernameAcceptsNormalName(t *testing.T) {
t.Fatalf("expected valid username, got %v", err)
}
}
func TestValidateUsernameRejectsUnsafeNames(t *testing.T) {
for _, username := range []string{"..", "....", ".", "-", "al..ice", "bob/", "./bob", `bob\`, "alice/docs", " alice", "alice "} {
if err := ValidateUsername(username); err == nil {
t.Fatalf("expected %q to be rejected", username)
}
}
for _, username := range []string{"alice", "first.last", "j.r.r", "a-b_c", "user@example.com", "José", "李雷", ".alice", "users"} {
if err := ValidateUsername(username); err != nil {
t.Fatalf("expected %q to be accepted, got %v", username, err)
}
}
}
+24 -28
View File
@@ -47,8 +47,10 @@ var reservedUsernames = map[string]struct{}{
strings.ToLower(AnonymousUserName): {},
}
// ValidateUsername rejects empty and reserved login names.
// ValidateUsername rejects empty and reserved login names, and names that do not map to their
// own createUserDir home directory.
func ValidateUsername(username string) error {
raw := username
username = strings.TrimSpace(username)
if username == "" {
return fmt.Errorf("username is empty")
@@ -56,6 +58,11 @@ func ValidateUsername(username string) error {
if _, reserved := reservedUsernames[strings.ToLower(username)]; reserved {
return fmt.Errorf("username %q is reserved", username)
}
// The per-user home directory is derived from CleanUsername(username), so reject names
// that it would rewrite, the names "." and "-", and names containing path separators.
if CleanUsername(raw) != raw || raw == "." || raw == "-" || strings.ContainsAny(raw, `/\`) {
return fmt.Errorf("username %q contains characters that are not allowed", raw)
}
return nil
}
@@ -97,6 +104,8 @@ type AuthToken struct {
BelongsTo uint64 `json:"belongsTo,omitempty"` // legacy stored metadata only, never trusted for auth; identity is always the hashed_tokens lookup
IssuedAt int64 `json:"issuedAt,omitempty"`
ExpiresAt int64 `json:"expiresAt,omitempty"`
// Minimal distinguishes owner-inheriting tokens from customized caps. Nil means legacy (infer from stored permissions).
Minimal *bool `json:"minimal,omitempty"`
Permissions Permissions `json:"Permissions,omitempty"`
}
@@ -173,17 +182,17 @@ type Preview struct {
// FrontendUser holds fields safe to return from user APIs (embedded on User).
type FrontendUser struct {
NonAdminEditable
DisableSettings bool `json:"disableSettings"`
Username string `json:"username"`
FrontendScopes []FrontendScope `json:"scopes"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"`
ShowFirstLogin bool `json:"showFirstLogin"`
EffectiveToolAccess map[string]bool `json:"effectiveToolAccess,omitempty"`
Perm Permissions `json:"perm,omitzero"`
DisableSettings bool `json:"disableSettings"`
Username string `json:"username"`
FrontendScopes []FrontendScope `json:"scopes"`
LockPassword bool `json:"lockPassword"`
RequirePasswordChange bool `json:"requirePasswordChange"`
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"`
ShowFirstLogin bool `json:"showFirstLogin"`
Perm Permissions `json:"perm,omitzero"`
}
// PinnedItems maps source filesystem path -> index directory path -> pinned item names.
@@ -201,7 +210,6 @@ type User struct {
// BackendScopes is the authoritative, persisted access list (SourceScope.Name = backend source path).
// SQLite stores this inside user_data JSON under the key "scopes" (see sqldb.UserData).
BackendScopes []BackendScope `json:"backendScopes,omitempty"`
DeclinedDefaultSources []string `json:"declinedDefaultSources,omitempty"`
BackendSourcePermissions map[string]SourceFilePermissions `json:"backendSourcePermissions,omitempty"` // key = source path
Tokens map[string]AuthToken `json:"tokens,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
@@ -212,24 +220,16 @@ type User struct {
UserLegacyFields `json:",inline"`
}
type ScopeQuota struct {
ID string `json:"id"`
LimitBytes int64 `json:"limitBytes"` // 0 = unlimited
Meter string `json:"meter,omitempty"` // index_scope (indexed size) | accounted (tracked usage)
}
type FrontendScope struct {
Name string `json:"name"` // Bolt: filesystem path; JSON API: display name after prepForFrontend
Scope string `json:"scope"` // index path within that source
Permissions *SourceFilePermissions `json:"permissions,omitempty"`
Quota *ScopeQuota `json:"quota,omitempty"`
}
type BackendScope struct {
Path string `json:"path"` // real path for the source
Scope string `json:"scope"` // index path within that source
Permissions SourceFilePermissions `json:"permissions,omitempty"`
Quota *ScopeQuota `json:"quota,omitempty"`
}
// json tags must match variable name with smaller case first letter
@@ -243,10 +243,9 @@ type NonAdminEditable struct {
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Password string `json:"password,omitempty"`
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
NewFileTemplate []string `json:"newFileTemplate"` // list of custom filenames that will be used as template for new files
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI and API
@@ -266,14 +265,11 @@ type NonAdminEditable struct {
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
ToolAccess ToolAccessMap `json:"toolAccess,omitempty"` // per-tool access overrides
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
PromptRightCloseButton bool `json:"promptRightCloseButton"` // show the prompts close button on the right
ShowFirstLogin bool `json:"showFirstLogin"`
PasskeyCredentials []WebAuthnCredential `json:"passkeyCredentials,omitempty"`
ShowAdvancedProfile bool `json:"showAdvancedProfile"`
}
type FileLoading struct {
+2 -2
View File
@@ -18,7 +18,8 @@ var (
ErrAccessDenied = errors.New("access denied")
ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent")
ErrNoTotpProvided = errors.New("OTP code is required for user")
ErrNoTotpProvided = errors.New("OTP code is required for user")
ErrPasswordChangeRequired = errors.New("password change is required for user")
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not configured")
ErrUnauthorized = errors.New("user unauthorized")
ErrNotIndexed = errors.New("directory or item excluded from indexing")
@@ -35,5 +36,4 @@ var (
ErrPasskeyExists = errors.New("passkey credential already exists")
ErrPasskeyNotEnabled = errors.New("passkey authentication is not enabled")
ErrPasskeyInvalidSession = errors.New("invalid or expired passkey session")
ErrQuotaExceeded = errors.New("quota exceeded")
)
+4 -20
View File
@@ -41,15 +41,13 @@ func handleUserEvents() {
for ue := range userEventChan {
for _, user := range ue.users {
userClientsMu.RLock()
conns := userClients[user]
userClientsMu.RUnlock()
for _, ch := range conns {
for _, ch := range userClients[user] {
select {
case ch <- ue.event:
default:
}
}
userClientsMu.RUnlock()
}
}
}
@@ -135,28 +133,14 @@ func DebouncedBroadcast(eventType, message string) {
func handleSourceUpdates() {
for update := range sourceUpdateChan {
sourceClientsMu.RLock()
clients := sourceClients[update.source]
clientCount := len(clients)
sourceClientsMu.RUnlock()
if clientCount == 0 {
// No clients registered for this source - this is normal if no one is connected
continue
}
sentCount := 0
for ch := range clients {
for ch := range sourceClients[update.source] {
select {
case ch <- update.event:
sentCount++
default:
// Channel full, message dropped
}
}
// Log if we have clients but couldn't send to all
//if sentCount < clientCount {
// // Some messages were dropped due to full channels
//}
sourceClientsMu.RUnlock()
}
}
@@ -0,0 +1,74 @@
package events
import (
"sync"
"testing"
"time"
)
func TestConcurrentRegisterUnregisterSourceUpdates(t *testing.T) {
stop := make(chan struct{})
go func() {
for {
select {
case <-stop:
return
default:
ch := Register("stress-user", []string{"stress-source"})
Unregister("stress-user", ch)
}
}
}()
for i := 0; i < 200; i++ {
SendSourceUpdate("stress-source", "tick")
}
close(stop)
time.Sleep(50 * time.Millisecond)
}
func TestConcurrentSSEClientLifecycleRace(t *testing.T) {
stop := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
user := "race-user-" + string(rune('a'+id))
source := "race-source-" + string(rune('a'+id%2))
for {
select {
case <-stop:
return
default:
ch := Register(user, []string{source, "shared-source"})
SendSourceUpdate(source, "tick")
SendSourceUpdate("shared-source", "tick")
SendToUsers("ping", "msg", []string{user})
Unregister(user, ch)
}
}
}(i)
}
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
SendSourceUpdate("shared-source", "broadcast")
SendToUsers("broadcast", "all", []string{"race-user-a", "race-user-b"})
}
}
}()
time.Sleep(100 * time.Millisecond)
Shutdown()
close(stop)
wg.Wait()
time.Sleep(50 * time.Millisecond)
}
+10
View File
@@ -69,6 +69,11 @@ func (s *Service) ProbeFile(ctx context.Context, mediaPath string) (*FileProbeIn
return nil, err
}
if err = s.Acquire(ctx); err != nil {
return nil, err
}
defer s.Release()
info, err := s.inner.ProbeStream(ctx, goffmpeg.ProbeStreamOptions{
URL: localPath,
StreamType: goffmpeg.StreamFile,
@@ -101,6 +106,11 @@ func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float
return duration, nil
}
if err = s.Acquire(ctx); err != nil {
return 0, err
}
defer s.Release()
duration, err := s.inner.GetMediaDuration(ctx, localPath)
if err != nil {
return 0, err
+23 -16
View File
@@ -27,8 +27,7 @@ var global *Service
// InitOptions configures startup initialization.
type InitOptions struct {
FFmpegPath string
MaxConcurrent int // deprecated: use Concurrency
Concurrency goffmpeg.Concurrency
MaxConcurrent int
CacheDir string
SkipHWTests bool
HardwareAcceleration bool
@@ -36,6 +35,9 @@ type InitOptions struct {
// Initialize creates the global ffmpeg service and runs capability detection.
func Initialize(ctx context.Context, opts InitOptions) error {
if opts.MaxConcurrent < 1 {
opts.MaxConcurrent = 4
}
if opts.CacheDir == "" {
opts.CacheDir = os.TempDir()
}
@@ -44,20 +46,12 @@ func Initialize(ctx context.Context, opts InitOptions) error {
logger.Info("Detecting ffmpeg hardware codec support...")
}
cfg := goffmpeg.Config{
svc, err := goffmpeg.New(ctx, goffmpeg.Config{
FFmpegPath: opts.FFmpegPath,
Concurrency: opts.Concurrency,
MaxConcurrent: opts.MaxConcurrent,
Logger: goffmpeg.NopLogger(),
SkipHWTests: opts.SkipHWTests,
}
if opts.Concurrency.MaxProbe == 0 && opts.Concurrency.MaxDecode == 0 && opts.Concurrency.MaxEncode == 0 {
if opts.MaxConcurrent < 1 {
opts.MaxConcurrent = 4
}
cfg.MaxConcurrent = opts.MaxConcurrent
}
svc, err := goffmpeg.New(ctx, cfg)
})
if err != nil {
global = nil
return err
@@ -113,12 +107,20 @@ func (s *Service) Release() {
}
// VideoPreview extracts a JPEG preview frame to w.
// go-ffmpeg Service methods acquire concurrency slots automatically; do not Acquire before calling.
func (s *Service) VideoPreview(ctx context.Context, w io.Writer, opts ops.PreviewOptions) error {
func (s *Service) VideoPreview(ctx context.Context, w io.Writer, videoPath string, percentageSeek int) error {
if s == nil || s.inner == nil {
return fmt.Errorf("ffmpeg service not available")
}
return s.inner.VideoPreview(ctx, w, opts)
if err := s.Acquire(ctx); err != nil {
return err
}
defer s.Release()
return s.inner.VideoPreview(ctx, w, ops.PreviewOptions{
Input: videoPath,
SeekPercent: float64(percentageSeek),
Quality: 10,
})
}
// ExtractSubtitle returns embedded subtitle content as WebVTT.
@@ -136,6 +138,11 @@ func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamI
return content, nil
}
if err = s.Acquire(ctx); err != nil {
return "", err
}
defer s.Release()
content, err := s.inner.ExtractSubtitle(ctx, videoPath, streamIndex)
if err != nil {
return "", err
@@ -1,106 +0,0 @@
package ffmpeg
import (
"context"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
"time"
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
)
func requireFFmpegBinaries(t *testing.T) {
t.Helper()
if os.Getenv("GOFFMPEG_FFMPEG_PATH") != "" {
return
}
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not available")
}
if _, err := exec.LookPath("ffprobe"); err != nil {
t.Skip("ffprobe not available")
}
}
func testSampleMP4(t *testing.T) string {
t.Helper()
if p := os.Getenv("GOFFMPEG_SAMPLE_MP4"); p != "" {
return p
}
dir := t.TempDir()
out := filepath.Join(dir, "sample.mp4")
cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "color=c=blue:s=320x240:d=2",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", out)
if err := cmd.Run(); err != nil {
t.Skipf("could not generate sample mp4: %v", err)
}
return out
}
// TestGetMediaDurationConcurrentNoDeadlock ensures probe-tier concurrency does not deadlock.
// With MaxProbe=2, four concurrent GetMediaDuration calls queue and complete.
func TestGetMediaDurationConcurrentNoDeadlock(t *testing.T) {
requireFFmpegBinaries(t)
sample := testSampleMP4(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := Initialize(ctx, InitOptions{
Concurrency: goffmpeg.Concurrency{
MaxProbe: 2,
MaxDecode: 4,
MaxEncode: 2,
},
CacheDir: t.TempDir(),
SkipHWTests: true,
HardwareAcceleration: false,
})
if err != nil {
t.Fatalf("Initialize() error = %v", err)
}
t.Cleanup(func() { global = nil })
svc := Get()
if svc == nil {
t.Fatal("Get() returned nil after Initialize")
}
const workers = 4
var wg sync.WaitGroup
errCh := make(chan error, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := svc.GetMediaDuration(ctx, sample); err != nil {
errCh <- err
}
}()
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(25 * time.Second):
t.Fatal("concurrent GetMediaDuration calls deadlocked with MaxProbe=2")
}
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("GetMediaDuration() error = %v", err)
}
}
}
+6 -5
View File
@@ -78,12 +78,10 @@ func extractTIFFEmbeddedPreview(f *os.File) ([]byte, error) {
return nil, err
}
ex, err := extimagemeta.Decode(f)
if err != nil {
return nil, err
var candidates []previewCandidate
if ex, err := extimagemeta.Decode(f); err == nil {
candidates = previewCandidates(ex)
}
candidates := previewCandidates(ex)
if scanCandidates, scanErr := collectTIFFPreviewCandidates(f); scanErr == nil {
candidates = append(candidates, scanCandidates...)
}
@@ -115,6 +113,9 @@ func previewCandidates(ex exif.Exif) []previewCandidate {
out = append(out, previewCandidate{offset: ifd.ImageOffset, length: ifd.ImageLength})
}
}
for _, sub := range ex.SubIFDs {
appendIFD(sub)
}
appendIFD(ex.IFD1)
appendIFD(ex.IFD2)
+20 -55
View File
@@ -5,15 +5,10 @@ package imagemeta
import (
"encoding/binary"
"io"
"math"
"os"
)
const (
heicMetadataInitialScan = 256 * 1024
heicMetadataScanLimit = 32 * 1024 * 1024
maxBMFFRecursionDepth = 64
)
const heicMetadataScanLimit = 32 * 1024 * 1024
type heicTransform struct {
irot uint8
@@ -28,37 +23,19 @@ func parseHEICTransform(f *os.File) heicTransform {
if err != nil {
return heicTransform{}
}
fileSize := info.Size()
if fileSize < 12 {
size := info.Size()
if size > heicMetadataScanLimit {
size = heicMetadataScanLimit
}
if size < 12 {
return heicTransform{}
}
scanLimit := int64(heicMetadataInitialScan)
if fileSize < scanLimit {
scanLimit = fileSize
}
for {
buf := make([]byte, scanLimit)
n, readErr := f.ReadAt(buf, 0)
if readErr != nil && readErr != io.EOF {
return heicTransform{}
}
data := buf[:n]
if result := parseHEICPrimaryIrot(data); result.found || findBMFFBox(data, "meta") != nil {
return result
}
if scanLimit >= fileSize || scanLimit >= heicMetadataScanLimit {
return parseHEICPrimaryIrot(data)
}
next := scanLimit * 2
if next > heicMetadataScanLimit {
next = heicMetadataScanLimit
}
if next > fileSize {
next = fileSize
}
scanLimit = next
buf := make([]byte, size)
n, err := f.ReadAt(buf, 0)
if err != nil && err != io.EOF {
return heicTransform{}
}
return parseHEICPrimaryIrot(buf[:n])
}
func parseHEICPrimaryIrot(data []byte) heicTransform {
@@ -98,13 +75,10 @@ func parseHEICPrimaryIrot(data []byte) heicTransform {
}
func findBMFFBox(data []byte, typ string) []byte {
return findBMFFBoxRange(data, 0, len(data), typ, 0)
return findBMFFBoxRange(data, 0, len(data), typ)
}
func findBMFFBoxRange(data []byte, start, end int, typ string, depth int) []byte {
if depth > maxBMFFRecursionDepth {
return nil
}
func findBMFFBoxRange(data []byte, start, end int, typ string) []byte {
pos := start
for pos+8 <= end {
size, header, ok := bmffBoxHeader(data, pos, end)
@@ -112,14 +86,14 @@ func findBMFFBoxRange(data []byte, start, end int, typ string, depth int) []byte
break
}
boxEnd := pos + size
if boxEnd > end || boxEnd < pos {
if boxEnd > end {
break
}
if string(data[pos+4:pos+8]) == typ {
return data[pos:boxEnd]
}
if isBMFFContainerType(data[pos+4 : pos+8]) {
if child := findBMFFBoxRange(data, pos+header, boxEnd, typ, depth+1); child != nil {
if child := findBMFFBoxRange(data, pos+header, boxEnd, typ); child != nil {
return child
}
}
@@ -133,7 +107,7 @@ func findBMFFChildBox(container []byte, typ string) []byte {
if payload == nil {
return nil
}
return findBMFFBoxRange(payload, 0, len(payload), typ, 0)
return findBMFFBoxRange(payload, 0, len(payload), typ)
}
func isBMFFContainerType(typ []byte) bool {
@@ -157,14 +131,7 @@ func bmffBoxHeader(data []byte, pos, end int) (size int, header int, ok bool) {
if pos+16 > end {
return 0, 0, false
}
size64 := binary.BigEndian.Uint64(data[pos+8 : pos+16])
if size64 < 16 || size64 > math.MaxInt32 {
return 0, 0, false
}
return int(size64), 16, true
}
if size32 < 8 {
return 0, 0, false
return int(binary.BigEndian.Uint64(data[pos+8 : pos+16])), 16, true
}
return int(size32), 8, true
}
@@ -189,7 +156,7 @@ func bmffBoxPayload(box []byte) []byte {
func isBMFFFullBoxType(typ string) bool {
switch typ {
case "meta", "pitm", "ispe", "ipma", "hdlr", "iloc", "iinf", "infe":
case "meta", "pitm", "irot", "imir", "ispe", "ipma", "hdlr", "iloc", "iinf", "infe":
return true
default:
return false
@@ -235,10 +202,8 @@ func parseIPCOProperties(ipco []byte) []ipcoProperty {
}
child := payload[pos : pos+size]
prop := ipcoProperty{typ: string(child[4:8])}
if prop.typ == "irot" {
if payload := bmffBoxPayload(child); len(payload) >= 1 {
prop.irot = payload[0] & 0x03
}
if prop.typ == "irot" && len(child) >= 13 {
prop.irot = child[12] & 0x03
}
props = append(props, prop)
pos += size
@@ -97,33 +97,6 @@ func TestGetOrientationSyntheticHEICRotate(t *testing.T) {
}
}
func TestFindBMFFBoxRangeRespectsDepthLimit(t *testing.T) {
target := bmffBox("targ", []byte{1, 2, 3})
nested := target
for i := 0; i < maxBMFFRecursionDepth+4; i++ {
nested = bmffBox("meta", nested)
}
if got := findBMFFBoxRange(nested, 0, len(nested), "targ", 0); got != nil {
t.Fatal("expected depth limit to prevent finding deeply nested box")
}
}
func TestParseIPCOPropertiesReadsPlainIrotBox(t *testing.T) {
ipco := bmffBox("ipco", bmffIrotBox(2))
props := parseIPCOProperties(ipco)
if len(props) != 1 || props[0].irot != 2 {
t.Fatalf("parseIPCOProperties() = %+v, want irot=2", props)
}
}
func TestParseIPCOPropertiesReadsExtendedSizeIrotBox(t *testing.T) {
ipco := bmffBox("ipco", bmffExtendedIrotBox(1))
props := parseIPCOProperties(ipco)
if len(props) != 1 || props[0].irot != 1 {
t.Fatalf("parseIPCOProperties() = %+v, want irot=1 from extended-size box", props)
}
}
type heicItemFixture struct {
itemID uint16
irot uint8
@@ -247,18 +220,7 @@ func bmffPITMBox(itemID uint16) []byte {
}
func bmffIrotBox(angle uint8) []byte {
return bmffBox("irot", []byte{angle & 0x03})
}
func bmffExtendedIrotBox(angle uint8) []byte {
payload := []byte{angle & 0x03}
size := 16 + len(payload)
out := make([]byte, size)
binary.BigEndian.PutUint32(out[0:4], 1)
copy(out[4:8], "irot")
binary.BigEndian.PutUint64(out[8:16], uint64(size))
copy(out[16:], payload)
return out
return bmffFullBox("irot", 0, []byte{angle & 0x03})
}
func bmffIPMABox(entries []ipmaEntryFixture) []byte {
+8 -7
View File
@@ -47,15 +47,16 @@ func GetOrientation(ctx context.Context, path string) string {
default:
ex, err = extimagemeta.Decode(f)
}
if err != nil {
return ""
}
if ctx.Err() != nil {
return ""
}
if ex.IFD0.Orientation == 0 {
return ""
if err == nil && ex.IFD0.Orientation != 0 {
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(ex.IFD0.Orientation))
}
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(ex.IFD0.Orientation))
if !isHEICExtension(ext) {
if orient := extractTIFFOrientation(f); orient != 0 {
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(orient))
}
}
return ""
}
+89 -1
View File
@@ -9,11 +9,18 @@ import (
)
const (
tiffTagStripOffsets = 0x0111
tiffTagOrientation = 0x0112
tiffTagStripByteCounts = 0x0117
tiffTagSubIFDs = 0x014a
tiffTagJpgFromRaw = 0x002e
tiffTagJPEGInterchange = 0x0201
tiffTagJPEGInterchangeLen = 0x0202
tiffIFDScanLimit = 4 * 1024 * 1024
tiffMagicStandard = 42
tiffMagicPanasonicRW2 = 0x0055
tiffMagicOlympusORF1 = 0x4f52
tiffMagicOlympusORF2 = 0x5352
tiffTypeByte = 1
tiffTypeASCII = 2
tiffTypeShort = 3
@@ -97,6 +104,7 @@ func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
for _, ifd := range ifds {
out = appendPreviewCandidates(out, jpgFromRawCandidates(ifd))
out = appendPreviewCandidates(out, jpegInterchangeCandidates(ifd))
out = appendPreviewCandidates(out, stripPreviewCandidates(ifd, order))
if len(out) >= maxTIFFPreviewCandidates {
break
}
@@ -104,6 +112,42 @@ func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
return out, nil
}
// extractTIFFOrientation reads standard TIFF IFD0 Orientation (0x0112) as a fallback
// when full EXIF decoding fails.
func extractTIFFOrientation(f *os.File) uint16 {
info, err := f.Stat()
if err != nil {
return 0
}
scanSize := info.Size()
if scanSize > tiffIFDScanLimit {
scanSize = tiffIFDScanLimit
}
if scanSize < 8 {
return 0
}
header := make([]byte, scanSize)
n, err := f.ReadAt(header, 0)
if err != nil && err != io.EOF {
return 0
}
header = header[:n]
order, ifd0, ok := tiffHeaderIFD0(header)
if !ok || ifd0 == 0 {
return 0
}
ifd, _, _, err := parseTIFFIFD(header, order, ifd0)
if err != nil {
return 0
}
tag, ok := ifd.tags[tiffTagOrientation]
if !ok || tag.count == 0 {
return 0
}
return tiffTagFirstUint16(tag, order)
}
func appendPreviewCandidates(dst, src []previewCandidate) []previewCandidate {
remaining := maxTIFFPreviewCandidates - len(dst)
if remaining <= 0 {
@@ -144,6 +188,48 @@ func jpegInterchangeCandidates(ifd tiffIFD) []previewCandidate {
return []previewCandidate{{offset: offset, length: length}}
}
func stripPreviewCandidates(ifd tiffIFD, order binary.ByteOrder) []previewCandidate {
offTag, okOff := ifd.tags[tiffTagStripOffsets]
lenTag, okLen := ifd.tags[tiffTagStripByteCounts]
if !okOff || !okLen || offTag.count != 1 || lenTag.count != 1 {
return nil
}
offset := tiffTagScalarUint32(offTag, order)
length := tiffTagScalarUint32(lenTag, order)
if offset == 0 || length <= 4 || length > maxPreviewReadSize {
return nil
}
return []previewCandidate{{offset: offset, length: length}}
}
func tiffTagScalarUint32(t tiffTag, order binary.ByteOrder) uint32 {
switch t.typ {
case tiffTypeLong, tiffTypeIFD:
return t.valueOffset
case tiffTypeShort:
return uint32(tiffTagFirstUint16(t, order))
default:
return 0
}
}
func tiffTagFirstUint16(t tiffTag, order binary.ByteOrder) uint16 {
switch t.typ {
case tiffTypeShort:
if order == binary.BigEndian {
return uint16(t.valueOffset >> 16)
}
return uint16(t.valueOffset & 0xffff)
case tiffTypeLong:
if t.valueOffset > 0xffff {
return 0
}
return uint16(t.valueOffset)
default:
return 0
}
}
func tiffHeaderIFD0(data []byte) (binary.ByteOrder, uint32, bool) {
if len(data) < 8 {
return nil, 0, false
@@ -157,7 +243,9 @@ func tiffHeaderIFD0(data []byte) (binary.ByteOrder, uint32, bool) {
default:
return nil, 0, false
}
if order.Uint16(data[2:4]) != 42 {
switch order.Uint16(data[2:4]) {
case tiffMagicStandard, tiffMagicPanasonicRW2, tiffMagicOlympusORF1, tiffMagicOlympusORF2:
default:
return nil, 0, false
}
return order, order.Uint32(data[4:8]), true
+135 -3
View File
@@ -62,7 +62,7 @@ func TestCollectTIFFPreviewCandidatesIgnoresMalformedOffsets(t *testing.T) {
func TestCollectTIFFPreviewCandidatesIgnoresMalformedSubIFDOffset(t *testing.T) {
data := buildSyntheticTIFF(tiffFixture{
thumbnailJPEG: smallJPEG,
subIFDJPEG: largeJPEG,
subIFDJPEG: largeJPEG,
badJpgFromRaw: true,
})
f := openBytesFile(t, data)
@@ -114,6 +114,138 @@ func TestTiffTagUint32sSupportsIFDType(t *testing.T) {
}
}
func TestExtractEmbeddedPreviewFallbackWhenDecodeFails(t *testing.T) {
// Synthetic ARW where SubIFD offset is placed after IFD2, and a SubIFD tag
// points past EOF so extimagemeta.Decode fails with EOF, while IFD2 holds a
// valid JPEGInterchangeFormat preview.
order := binary.LittleEndian
buf := make([]byte, 32*1024)
copy(buf[0:2], []byte("II"))
order.PutUint16(buf[2:4], tiffMagicStandard)
ifd0Off := uint32(8)
order.PutUint32(buf[4:8], ifd0Off)
thumbOff := uint32(4096)
copy(buf[thumbOff:], smallJPEG)
largeOff := uint32(8192)
copy(buf[largeOff:], largeJPEG)
ifd1Off := uint32(256)
ifd2Off := uint32(512)
subIFDOff := uint32(768)
writeIFD := func(offset uint32, entries []tiffEntry, next uint32) {
off := int(offset)
order.PutUint16(buf[off:off+2], uint16(len(entries)))
entryBase := off + 2
for i, entry := range entries {
pos := entryBase + i*12
order.PutUint16(buf[pos:pos+2], entry.tag)
order.PutUint16(buf[pos+2:pos+4], entry.typ)
order.PutUint32(buf[pos+4:pos+8], entry.count)
order.PutUint32(buf[pos+8:pos+12], entry.value)
}
order.PutUint32(buf[entryBase+len(entries)*12:entryBase+len(entries)*12+4], next)
}
writeIFD(ifd0Off, []tiffEntry{
{tag: tiffTagOrientation, typ: tiffTypeShort, count: 1, value: 6},
{tag: tiffTagSubIFDs, typ: tiffTypeLong, count: 1, value: subIFDOff},
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(smallJPEG))},
}, ifd1Off)
writeIFD(ifd1Off, []tiffEntry{
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(smallJPEG))},
}, ifd2Off)
writeIFD(ifd2Off, []tiffEntry{
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: largeOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(largeJPEG))},
}, 0)
// SubIFD has a StripOffsets tag with count=2 pointing beyond file end so Decode fails with EOF.
writeIFD(subIFDOff, []tiffEntry{
{tag: tiffTagStripOffsets, typ: tiffTypeLong, count: 2, value: uint32(len(buf) + 4096)},
}, 0)
path := filepath.Join(t.TempDir(), "sample.arw")
if err := os.WriteFile(path, buf[:int(largeOff)+len(largeJPEG)], 0o600); err != nil {
t.Fatal(err)
}
preview, err := ExtractEmbeddedPreview(t.Context(), path)
if err != nil {
t.Fatalf("ExtractEmbeddedPreview() err = %v", err)
}
if len(preview) != len(largeJPEG) {
t.Fatalf("ExtractEmbeddedPreview() len = %d, want %d", len(preview), len(largeJPEG))
}
if got := GetOrientation(t.Context(), path); got != "Rotate 90 CW" {
t.Fatalf("GetOrientation() = %q, want %q", got, "Rotate 90 CW")
}
}
func TestExtractEmbeddedPreviewSupportsRW2AndStripPreview(t *testing.T) {
order := binary.LittleEndian
buf := make([]byte, 16*1024)
copy(buf[0:2], []byte("II"))
order.PutUint16(buf[2:4], tiffMagicPanasonicRW2)
ifd0Off := uint32(8)
order.PutUint32(buf[4:8], ifd0Off)
largeOff := uint32(4096)
copy(buf[largeOff:], largeJPEG)
entries := []tiffEntry{
{tag: tiffTagStripOffsets, typ: tiffTypeLong, count: 1, value: largeOff},
{tag: tiffTagStripByteCounts, typ: tiffTypeLong, count: 1, value: uint32(len(largeJPEG))},
}
order.PutUint16(buf[ifd0Off:ifd0Off+2], uint16(len(entries)))
for i, entry := range entries {
pos := int(ifd0Off) + 2 + i*12
order.PutUint16(buf[pos:pos+2], entry.tag)
order.PutUint16(buf[pos+2:pos+4], entry.typ)
order.PutUint32(buf[pos+4:pos+8], entry.count)
order.PutUint32(buf[pos+8:pos+12], entry.value)
}
path := filepath.Join(t.TempDir(), "sample.rw2")
if err := os.WriteFile(path, buf[:int(largeOff)+len(largeJPEG)], 0o600); err != nil {
t.Fatal(err)
}
preview, err := ExtractEmbeddedPreview(t.Context(), path)
if err != nil {
t.Fatalf("ExtractEmbeddedPreview() err = %v", err)
}
if len(preview) != len(largeJPEG) {
t.Fatalf("ExtractEmbeddedPreview() len = %d, want %d", len(preview), len(largeJPEG))
}
}
func TestExtractEmbeddedPreviewDSC02672(t *testing.T) {
path := "/Users/steffag/Downloads/DSC02672-first-4MB.ARW"
if _, err := os.Stat(path); err != nil {
t.Skip(path)
}
got, err := ExtractEmbeddedPreview(t.Context(), path)
if err != nil {
t.Fatal(err)
}
if !IsJPEG(got) {
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
}
if len(got) != 2954147 {
t.Fatalf("preview size = %d, want 2954147", len(got))
}
if orient := GetOrientation(t.Context(), path); orient != "Horizontal (normal)" {
t.Fatalf("GetOrientation() = %q, want Horizontal (normal)", orient)
}
}
type tiffFixture struct {
thumbnailJPEG []byte
subIFDJPEG []byte
@@ -195,8 +327,8 @@ func buildSyntheticTIFF(fix tiffFixture) []byte {
}
type tiffEntry struct {
tag, typ uint16
count, value uint32
tag, typ uint16
count, value uint32
}
func buildChainedTIFF(chainLen int) []byte {
@@ -8,7 +8,6 @@ package preview
#include <mupdf/fitz.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
static void fb_silence_warnings(void *user, const char *message) {
(void)user;
@@ -18,25 +17,6 @@ static void fb_silence_warnings(void *user, const char *message) {
static double fb_dmin(double a, double b) { return a < b ? a : b; }
static double fb_dmax(double a, double b) { return a > b ? a : b; }
static int fb_rect_usable(fz_rect r) {
if (fz_is_infinite_rect(r) || fz_is_empty_rect(r)) {
return 0;
}
if (r.x1 <= r.x0 || r.y1 <= r.y0) {
return 0;
}
if (!isfinite(r.x0) || !isfinite(r.x1) || !isfinite(r.y0) || !isfinite(r.y1)) {
return 0;
}
return 1;
}
static int fb_bbox_within(fz_irect bbox, int max_w, int max_h) {
int w = bbox.x1 - bbox.x0;
int h = bbox.y1 - bbox.y0;
return w > 0 && h > 0 && w <= max_w && h <= max_h;
}
// fb_render_document_page_jpeg renders one page to JPEG bytes.
// max_width/max_height <= 0 selects original mode (~150 DPI). Otherwise renders into that box.
// resize_mode_fill: 0 = fit (no upscale), 1 = fill (center crop to exact box).
@@ -105,9 +85,6 @@ static int fb_render_document_page_jpeg(
page = fz_load_page(ctx, doc, page_num);
fz_rect bounds = fz_bound_page(ctx, page);
if (!fb_rect_usable(bounds)) {
fz_throw(ctx, FZ_ERROR_GENERIC, "invalid page bounds");
}
fz_matrix ctm;
fz_irect bbox;
@@ -115,32 +92,7 @@ static int fb_render_document_page_jpeg(
double dpi = 150.0;
ctm = fz_scale(dpi / 72.0, dpi / 72.0);
bounds = fz_transform_rect(bounds, ctm);
if (!fb_rect_usable(bounds)) {
fz_throw(ctx, FZ_ERROR_GENERIC, "invalid transformed page bounds");
}
bbox = fz_round_rect(bounds);
{
const int max_orig_w = 4096;
const int max_orig_h = 4096;
if (!fb_bbox_within(bbox, max_orig_w, max_orig_h)) {
int w = bbox.x1 - bbox.x0;
int h = bbox.y1 - bbox.y0;
if (w <= 0 || h <= 0) {
fz_throw(ctx, FZ_ERROR_GENERIC, "invalid page bounds");
}
double scale = fb_dmin((double)max_orig_w / (double)w, (double)max_orig_h / (double)h);
ctm = fz_concat(ctm, fz_scale(scale, scale));
bounds = fz_bound_page(ctx, page);
bounds = fz_transform_rect(bounds, ctm);
if (!fb_rect_usable(bounds)) {
fz_throw(ctx, FZ_ERROR_GENERIC, "invalid transformed page bounds");
}
bbox = fz_round_rect(bounds);
if (!fb_bbox_within(bbox, max_orig_w, max_orig_h)) {
fz_throw(ctx, FZ_ERROR_GENERIC, "page exceeds maximum render size");
}
}
}
} else {
double page_w = bounds.x1 - bounds.x0;
double page_h = bounds.y1 - bounds.y0;
+10
View File
@@ -17,6 +17,11 @@ func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath stri
return nil, ctx.Err()
}
if err := s.ffmpegService.Acquire(ctx); err != nil {
return nil, err
}
defer s.ffmpegService.Release()
var quality string
switch previewSize {
case "large":
@@ -41,6 +46,11 @@ func (s *Service) convertImageWithFFmpeg(ctx context.Context, filePath string, p
return nil, ctx.Err()
}
if err := s.ffmpegService.Acquire(ctx); err != nil {
return nil, err
}
defer s.ffmpegService.Release()
var width, height int
var quality string
switch previewSize {
+15 -1
View File
@@ -76,6 +76,18 @@ func (x Format) toImaging() imaging.Format {
}
}
// encodable reports whether imaging can encode this format.
// imaging only encodes JPEG, PNG, GIF, TIFF and BMP; webp and the netpbm
// formats are decode-only, so previews for them must be written as JPEG.
func (x Format) encodable() bool {
switch x {
case FormatJpeg, FormatPng, FormatGif, FormatTiff, FormatBmp:
return true
default:
return false
}
}
/*
ENUM(
high
@@ -203,7 +215,9 @@ func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
var wrappedReader = in
var err error
if opts.Format == FormatHeic {
// HEIC has no encoder and webp/netpbm are decode-only in imaging; fall back
// to JPEG so preview generation does not fail with ErrUnsupportedFormat.
if !opts.Format.encodable() {
opts.Format = FormatJpeg
}
// Ensure JPEG quality is set based on quality setting
+8
View File
@@ -159,6 +159,14 @@ func TestService_Resize(t *testing.T) {
},
matcher: sizeMatcher(100, 100),
},
"resize webp falls back to jpeg": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Format: FormatWebp},
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/gray-sample.webp")
},
matcher: formatMatcher(FormatJpeg),
},
"resize with high quality": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityHigh},
source: func(t *testing.T) afero.File {
+4 -14
View File
@@ -272,21 +272,12 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended
case previewTypeImage:
ext := strings.ToLower(filepath.Ext(file.Name))
if iteminfo.IsRawImage(ext) {
if s.ffmpegService != nil {
previewBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err == nil && len(previewBytes) >= minPreviewSize {
return previewBytes, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
}
return nil, ErrUnsupportedFormat
}
return s.generateImagePreview(ctx, file, previewSize)
case previewTypeVideo:
return s.generateVideoPreviewBytes(ctx, file, previewSize, seekPercentage)
return s.generateVideoPreviewBytes(ctx, file, seekPercentage)
case previewTypeAudio:
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
@@ -331,7 +322,7 @@ func (s *Service) generateHEICPreview(ctx context.Context, file iteminfo.Extende
}
// generateVideoPreviewBytes generates preview frame from video file
func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string, seekPercentage int) ([]byte, error) {
func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.ExtendedFileInfo, seekPercentage int) ([]byte, error) {
// Check if this video format is enabled for preview generation
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if !settings.CanConvertVideo(ext) {
@@ -343,7 +334,7 @@ func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.E
videoSeekPercentage = 10
}
imageBytes, err := s.GenerateVideoPreview(ctx, file.RealPath, previewSize, videoSeekPercentage)
imageBytes, err := s.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
if err != nil {
// Don't log client cancellations as errors
if ctx.Err() != context.Canceled {
@@ -506,8 +497,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
// When we got bytes from type-specific path, regular images are already resized; others need resize below.
previewType := determinePreviewType(file)
if !fromEmbeddedPreview && (previewType == previewTypeImage ||
(previewType == previewTypeDocument && settings.Env.MuPdfAvailable) ||
(previewType == previewTypeVideo && settings.Env.FFmpegAvailable)) {
(previewType == previewTypeDocument && settings.Env.MuPdfAvailable)) {
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", err)
}
+2 -16
View File
@@ -6,16 +6,14 @@ import (
"fmt"
"os"
"github.com/gtsteffaniak/go-ffmpeg/ops"
"github.com/gtsteffaniak/go-logger/logger"
)
// GenerateVideoPreview generates a single preview image from a video using ffmpeg.
// videoPath: path to the input video file.
// previewSize: small, large, xlarge, or original.
// percentageSeek: percentage of video duration to seek to (0–100).
// Returns: JPEG image bytes.
func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath, previewSize string, percentageSeek int) ([]byte, error) {
func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath string, percentageSeek int) ([]byte, error) {
if s.ffmpegService == nil {
logger.Errorf("FFmpeg service not available for file '%s'", videoPath)
return nil, fmt.Errorf("FFmpeg service not available")
@@ -27,20 +25,8 @@ func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath, previewSi
return nil, fmt.Errorf("video file not accessible: %w", err)
}
params, err := ffmpegPreviewParamsForSize(previewSize)
if err != nil {
return nil, err
}
var buf bytes.Buffer
err = s.ffmpegService.VideoPreview(ctx, &buf, ops.PreviewOptions{
Input: videoPath,
SeekPercent: float64(percentageSeek),
Width: params.width,
Height: params.height,
ScaleMode: params.scaleMode,
Quality: params.quality,
})
err := s.ffmpegService.VideoPreview(ctx, &buf, videoPath, percentageSeek)
if err != nil {
return nil, err
}
-35
View File
@@ -1,35 +0,0 @@
package preview
import (
"github.com/gtsteffaniak/go-ffmpeg/ops"
)
type ffmpegPreviewParams struct {
width int
height int
scaleMode ops.ScaleMode
quality int
}
func ffmpegPreviewParamsForSize(previewSize string) (ffmpegPreviewParams, error) {
if previewSize == "original" {
return ffmpegPreviewParams{quality: 10}, nil
}
opts, err := getPreviewOptions(previewSize)
if err != nil {
return ffmpegPreviewParams{}, err
}
params := ffmpegPreviewParams{
width: opts.Width,
height: opts.Height,
quality: 10, // fastest MJPEG encode (-q:v 10); visual quality comes from target dimensions
}
if opts.ResizeMode == ResizeModeFill {
params.scaleMode = ops.ScaleFill
} else {
params.scaleMode = ops.ScaleFit
}
return params, nil
}
@@ -1,73 +0,0 @@
package preview
import (
"testing"
"github.com/gtsteffaniak/go-ffmpeg/ops"
"github.com/stretchr/testify/require"
)
func TestFfmpegPreviewParamsForSize(t *testing.T) {
t.Parallel()
tests := []struct {
name string
size string
wantW int
wantH int
wantMode ops.ScaleMode
wantQual int
wantError bool
}{
{
name: "small fill",
size: "small",
wantW: 256,
wantH: 256,
wantMode: ops.ScaleFill,
wantQual: 10,
},
{
name: "large fit",
size: "large",
wantW: 640,
wantH: 640,
wantMode: ops.ScaleFit,
wantQual: 10,
},
{
name: "xlarge fit",
size: "xlarge",
wantW: 1024,
wantH: 1024,
wantMode: ops.ScaleFit,
wantQual: 10,
},
{
name: "original no scale",
size: "original",
wantQual: 10,
},
{
name: "unsupported",
size: "huge",
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ffmpegPreviewParamsForSize(tt.size)
if tt.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantW, got.width)
require.Equal(t, tt.wantH, got.height)
require.Equal(t, tt.wantMode, got.scaleMode)
require.Equal(t, tt.wantQual, got.quality)
})
}
}
-72
View File
@@ -1,72 +0,0 @@
package quota
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
// CopyMoveContext describes a copy/move/rename admission check.
type CopyMoveContext struct {
Principal *users.User
Action string
FromSourceName string
ToSourceName string
FromSourcePath string
ToSourcePath string
FromIndexPath string
ToIndexPath string
IsSrcDir bool
OverwriteBytes int64
ShareHash string
ShareLimitBytes int64
SessionID string
}
// ReserveCopyMove reserves quota before a copy/move/rename.
func ReserveCopyMove(ctx CopyMoveContext) error {
itemSize, ok := state.IndexedPathBytes(ctx.FromSourceName, ctx.FromIndexPath, ctx.IsSrcDir)
if !ok {
if state.HasApplicableQuotas(ctx.Principal, ctx.ToSourcePath, ctx.ToIndexPath, ctx.ShareHash, ctx.ShareLimitBytes) {
return newError(CodeUsageUnknown, "", "", 0, 0, 0, "indexed size unavailable for quota check")
}
return nil
}
checks := state.BuildCopyMoveReserveChecks(
ctx.Principal,
ctx.ToSourceName,
ctx.ToSourcePath,
ctx.FromIndexPath,
ctx.ToIndexPath,
ctx.Action,
itemSize,
ctx.OverwriteBytes,
ctx.ShareHash,
ctx.ShareLimitBytes,
)
if len(checks) == 0 {
return nil
}
if err := state.ReserveQuota(ctx.SessionID, checks); err != nil {
return mapReserveError(err, checks)
}
return nil
}
// CommitCopyMove commits reserved quota after a successful copy/move/rename.
func CommitCopyMove(sessionID string) error {
return state.CommitQuota(sessionID, 0)
}
// ReleaseCopyMove releases quota reservation on failure.
func ReleaseCopyMove(sessionID string) {
state.ReleaseQuota(sessionID)
}
// OverwriteBytesAtPath returns indexed bytes at dest when replacing an existing path.
func OverwriteBytesAtPath(sourceName, indexPath string, isDir bool) int64 {
size, ok := state.IndexedPathBytes(sourceName, indexPath, isDir)
if !ok {
return 0
}
return size
}
Loaded 100 of 513 files, more files were not shown because too many files have changed in this diff. Show more