Compare commits

..
Author SHA1 Message Date
Graham Steffaniak 91bc608f0e Beta/v2.1.2 (#3142) 2026-10-13 09:58:57 -05:00
Graham Steffaniak e21d553559 updated changelog for v2.1.2 (#3140) 2026-10-13 09:53:20 -05:00
Graham Steffaniak fde6857344 Merge branch 'main' into dev/v2.1.2 2026-10-13 09:33:59 -05:00
Graham Steffaniak 37aec9ebd8 fix: reject usernames that do not map to their own home directory (#3138) 2026-10-13 09:28:42 -05:00
dl-pt 4bf1ba312a fix(auth): prevent collisions between newly issued tokens (#3135) 2026-10-12 18:14:54 -05:00
+5 c8459eb404 fix: WebDAV OPTIONS (#3123)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: spatiumstas <79056064+spatiumstas@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Andreas Fehn <fehnomenal@fehn.systems>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Takahiro Shimizu <anatofuz@gmail.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
Co-authored-by: Joel Town Road <13539685+hippi345@users.noreply.github.com>
Co-authored-by: beemines <3083452174@qq.com>
Co-authored-by: Bee mines <Beemines82@gmail.com>
Co-authored-by: dweebzxx <134675545+dweebzxx@users.noreply.github.com>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
2026-10-12 12:15:16 -05:00
Walid KarrayandClaude Opus 5.5 241bbd69b4 fix(download): revalidate single-file downloads so a changed file is not served stale (#3133)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:52:51 -05:00
Graham Steffaniak 9de21cd896 Announce v2.0.0-stable (#3126) 2026-10-11 11:52:49 -05:00
planetryanandClaude Opus 5.5 fcec78b57c add Basque (eu) translation and fix Spanish (es) strings (#3118)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:05:54 -05:00
Kevin B. 12f27133b5 Remove editor quick save (#3122) 2026-10-11 09:05:38 -05:00
Kevin B. 20a5d75d7e fix language switcher in builds (#3117) 2026-10-10 12:33:46 -05:00
+5 8f2e8add9a Beta/v2.1.1 (#3109)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: spatiumstas <79056064+spatiumstas@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Andreas Fehn <fehnomenal@fehn.systems>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Takahiro Shimizu <anatofuz@gmail.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
Co-authored-by: Joel Town Road <13539685+hippi345@users.noreply.github.com>
Co-authored-by: beemines <3083452174@qq.com>
Co-authored-by: Bee mines <Beemines82@gmail.com>
Co-authored-by: dweebzxx <134675545+dweebzxx@users.noreply.github.com>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
2026-10-09 19:18:51 -05:00
Stephan Paternotte 6e98a9d4c8 Update nl.json (#3089) 2026-10-07 14:42:20 -05:00
Andreas Fehn c74817b1ab update german translations (#3062) 2026-10-03 19:14:32 -05:00
Matt Van HornandMatt Van Horn ed9b29f2e1 fix: apply theme toggles on anonymous public shares (#3049)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-10-02 15:25:50 -05:00
423 changed files with 21578 additions and 9424 deletions

No files matched your search

+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"
+1
View File
@@ -46,6 +46,7 @@ frontend/.tmp-icons-*
/backend/internal/web/dist
/backend/internal/web/embed
/backend/debug
/backend/http
.DS_Store
node_modules
+73
View File
@@ -2,6 +2,54 @@
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.2
**Security**:
- [High] New accounts named with ".." breaks out of scoped folder on creation (GHSA-c84q-q5g6-r47f) -- reporter @dylanzonix
- [Medium] Fixed session token collision issue when multiple users login simultaneously. (GHSA-c9cr-w724-gqj3) (#3135) Thanks @dl-pt reporters: @Ishtiaque-Ahmed-Risat @AlexWhiplash @tonghuaroot
**Notes**:
- Removed "Editor quick save" setting (`userDefaults.fileViewer.quickSave`), the save button is now always present in the editor toolbar. (#3122)
- add Basque (eu) translation and fix Spanish (es) strings (#3118)
**Bug Fixes**:
- fix(webdav): OPTIONS now advertises only methods actually supported and permitted for the user; the DAV header reports class 1 only since LOCK/UNLOCK are not enabled (#3110)
- fix(download): revalidate single-file downloads so a changed file is not served stale (#3133)
- Language switcher not working (#3114) (#3117)
- Viewer showing a stale image after the file changed on disk: single-file downloads are now revalidated by the browser (#3132)
## v2.1.1
Note: this release also contains all changes from v2.0.10-beta changelog
**New Features**:
- User and group picker with partial, case-insensitive search when creating access rules (#3085), assigning groups on user edit, and choosing members on group edit.
- New `PUT /api/access/user-groups` endpoint that atomically replaces a user's group memberships.
- feat(search): configure the advanced search result limit (#3058) @beemines
- feat(listing): add sortable Date added and Type columns (#3052) @dweebzxx
- feat(archive): suggest and preview ZIP filename encodings (#3024) @AnaTofuZ
**Notes**:
- Group API breaking changes: `POST /api/access` returns 400 when `ruleCategory=group` names a nonexistent group or `ruleCategory=user` names a nonexistent user, group endpoints return 400/409 instead of 500 for client errors, and `PUT /api/access/group` accepts a `create` flag and returns `unknownMembers`. Existing database records are unaffected; no migration is required.
- update halloween theme (#3055)
- Add soft wrap option to the editor config (#3063)
- add better screenshot view handling (#3059)
- Update nl.json (#3089) @Stephan-P
- update german translations (#3062) @fehnomenal
- Update dev dependencies, add biome, fix types and linting (#3075) @Kurami32
**Bug Fixes**:
- Groups assigned in the user edit prompt now refresh the Groups settings list.
- Creating a group with an existing name now returns a conflict instead of silently overwriting its members.
- Access rules can no longer create empty groups from typos; group rules must reference an existing group.
- Empty group names are rejected and new group names must be 2-128 characters without control characters (existing records are unaffected); deleting or renaming a user now updates group memberships.
- fix(auth): use login endpoint for existing TOTP authentication (#3074)
- fix: apply theme toggles on anonymous public shares (#3049) @mvanhorn
- fix(frontend): keep source named files in extractSourceFromPath (#3099) (#3095)
- fix(auth): identify login fields for password autofill (#3077)
- fix(backend): skip denied paths in multi-file downloads, 404 when none remain (#3100) @hippi345
- fix: persist the user scope path chosen in the edit dialog (#3065) @mvanhorn
## v2.1.0
**Security**:
@@ -70,6 +118,31 @@ All notable changes to this project will be documented in this file. For commit
- On Linux, source partition totals sum distinct filesystems mounted under the source root (nested mounts) so usage bars match indexed content (#2761)
- Context menu stuck when opened from a listing in a prompt like move/copy or search (#3028).
## v2.0.10
**Security**:
- [High] Restricted custom API tokens (`minimal=false`) can no longer be upgraded to a full-permission session via `POST /api/auth/renew`; renew accepts only web session tokens registered in `hashed_tokens` (GHSA-6gr6-5qpq-888p) -- thanks @tao0845.
- [High] TOTP re-enrollment via `POST /api/auth/otp/generate` and `POST /api/auth/otp/verify` no longer allows anonymous callers to replace an existing second factor using only the account password; reset or replace requires an authenticated self or admin session (first-time enrollment without MFA unchanged) (GHSA-qx86-4v5r-26g5) -- thanks @tao0845.
- [Low] Public share lyrics and subtitle media routes now honor the share's file-viewer setting, download disable flag, and download limits, matching the public download route (GHSA-p7x3-p5jj-9xfh) -- thanks Yves Soete of Blacksight LLC. @yssoe
**New Features**:
- Require password change at next login for password-based users: new user setting `requirePasswordChange` (user defaults + per-user admin toggle), login blocked until the user sets a new password; bootstrap admins with a generated initial password get this automatically ([#2977](https://github.com/gtsteffaniak/filebrowser/issues/2977)). Generated bootstrap passwords use a speakable `word-xxxxx-xx` form (random word from a fixed list plus a 5-character and 2-character code) instead of a long hex string, for easier handoff before the forced change.
- Config YAML expands `$VAR` and `${VAR}` so values such as `userPassword: "${FILEBROWSER_LDAP_USER_PASSWORD}"` work as documented (#3042).
**Notes**:
- Sidebar navigation tree rows are real hyperlinks: middle-click, Ctrl/Cmd+click, and Shift+click use the browser’s default new-tab or new-window behavior.
- Sidebar source links can now switch between aggregated usage (default) and a root-filesystem-only view via a new "Limit disk usage to source filesystem" toggle.
- [docker] upgraded ffmpeg from 9.0 to 9.0.2
- CLI setup command no longer produces full config, instead a minimal config.yaml with comments.
**Bugfixes**:
- Fixed slow or broken file listing when `http.baseURL` is a subpath (for example `/files/`): the default source redirect navigated to `/files/{source}` on top of the app base URL, producing `/files/files/{source}` and resolving the wrong storage source until the route recovered.
- Anonymous visitors on public shares could not play inline video or audio: `GET /public/api/media/stream` returned 403 because view grants were validated before share context was fully hydrated; playback now works when download and the file viewer are allowed ([#3041](https://github.com/gtsteffaniak/filebrowser/issues/3041)).
- Fixed inflated disk usage for sources spanning multiple ZFS datasets (or btrfs subvolumes): shared-pool filesystems are now grouped by pool and counted once, instead of multiplying capacity by the number of mounted datasets (#3025) (#2997) (#2761).
- LDAP `userGroups` matching now accepts CN-only config values against full `memberOf` DNs and compares case-insensitively, instead of failing the shared auth helper with strict string equality (#3044).
- OIDC session expires despite tokenExpirationHours (#3006).
- Media playback becomes stuck after opening a failing media file since v2.0.8-beta (#3031)
## v2.0.9
**Security**:
+1 -1
View File
@@ -18,7 +18,7 @@
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
:pushpin: [v2.0.0 is now in beta!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/)
:pushpin: [v2.0.0 is now stable!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) --> See [migration support discussion](https://github.com/gtsteffaniak/filebrowser/discussions/3105)
## About
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:9.0.1-decode AS ffmpeg
FROM gtstef/ffmpeg:9.0.2-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
@@ -14,5 +14,9 @@ server:
config:
defaultEnabled: true
userDefaults:
listing:
viewMode: "normal"
# Size 4: switch-view "gallery" family resolves to icons (not gallery); list family → list.
gallerySize: 3
account:
showAdvancedProfile: true
@@ -12,7 +12,7 @@ import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
*/
export default defineConfig<PlaywrightFixtureOptions>({
globalSetup: "./tests/playwright/screenshots-setup.ts",
timeout: 5000,
timeout: 60_000,
testDir: "./tests/playwright/screenshots",
/* Run tests in files in parallel */
fullyParallel: false,
+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.Methods.PasswordAuth.AdminUsername = adminUsername
}
const adminPasswordGenerateDefault = "generate on startup"
adminPassword := askQuestion(reader, "What should the default admin password be?", adminPasswordGenerateDefault)
if adminPassword != "" && !strings.EqualFold(adminPassword, adminPasswordGenerateDefault) {
config.Auth.Methods.PasswordAuth.AdminPassword = adminPassword
}
modifyDefault := askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
config.Server.Sources[0].Config.DefaultPermissions = settings.NormalizeSourceFilePermissions(users.SourceFilePermissions{
@@ -266,12 +268,12 @@ func createConfig(configpath string, noInput bool) error {
fmt.Println("--- Configuration Complete ---")
yamlData, err := yaml.Marshal(&config)
yamlData, err := generateSetupConfigYAML(&config)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
return fmt.Errorf("generating config YAML: %w", err)
}
err = os.WriteFile(configpath, yamlData, 0600)
err = os.WriteFile(configpath, []byte(yamlData), 0600)
if err != nil {
return fmt.Errorf("writing config file: %w", err)
}
@@ -291,6 +293,22 @@ func createConfig(configpath string, noInput bool) error {
return nil
}
func generateSetupConfigYAML(config *settings.Settings) (string, error) {
embeddedPaths := []string{
"frontend/public/config.generated.yaml",
"../frontend/public/config.generated.yaml",
"internal/web/dist/config.generated.yaml",
"../internal/web/dist/config.generated.yaml",
}
for _, path := range embeddedPaths {
embeddedYaml, err := os.ReadFile(path)
if err == nil && len(embeddedYaml) > 0 {
return settings.GenerateConfigYamlWithEmbedded(config, true, false, false, string(embeddedYaml))
}
}
return settings.GenerateConfigYaml(config, true, false, false)
}
func generateYaml() {
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
if generateConfig || settings.Env.IsDevMode {
+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:")
}
-1
View File
@@ -18,7 +18,6 @@ server:
userDefaults:
fileViewer:
editorQuickSave: false
disableOnlyOfficeExt: ".md .txt .pdf"
disableViewingExt: ""
sidebar:
+1 -1
View File
@@ -35,6 +35,7 @@ require (
golang.org/x/oauth2 v0.37.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/text v0.42.0
golang.org/x/time v0.16.0
golang.org/x/tools v0.50.0
gopkg.in/yaml.v3 v3.0.1
@@ -90,7 +91,6 @@ require (
go.etcd.io/bbolt v1.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
-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)
@@ -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/")
}
+213
View File
@@ -0,0 +1,213 @@
// Package archiveencoding decodes ZIP entry names before path normalization.
package archiveencoding
import (
"archive/zip"
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/encoding/korean"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/encoding/traditionalchinese"
)
// ErrEncoding indicates a filename encoding that needs a different user selection.
var ErrEncoding = errors.New("invalid ZIP filename encoding")
type codec struct {
name string
encoding encoding.Encoding
}
var codecs = []codec{
{"utf-8", nil},
{"cp932", japanese.ShiftJIS},
{"gb18030", simplifiedchinese.GB18030},
{"big5", traditionalchinese.Big5},
{"euc-kr", korean.EUCKR},
{"cp437", charmap.CodePage437},
{"windows-1252", charmap.Windows1252},
}
// Candidate contains a bounded preview, decoded exactly as during extraction.
type Candidate struct {
Encoding string `json:"encoding"`
Names []string `json:"names"`
}
// Preview describes compatible encodings. An empty suggestion requires a choice;
// a heuristic suggestion is never a guarantee that the names are correct.
type Preview struct {
Suggested string `json:"suggested"`
Candidates []Candidate `json:"candidates"`
}
// unicodeName prefers the UTF-8 flag, then a verified Info-ZIP Unicode Path field.
// A Unicode Path field is usable only when its version and original-name CRC match.
func unicodeName(f *zip.File) (string, bool) {
if f.Flags&0x800 != 0 {
return f.Name, true
}
extra := f.Extra
for len(extra) >= 4 {
tag := binary.LittleEndian.Uint16(extra)
size := int(binary.LittleEndian.Uint16(extra[2:]))
extra = extra[4:]
if size > len(extra) {
break
}
field := extra[:size]
extra = extra[size:]
if tag == 0x7075 && len(field) > 5 && field[0] == 1 &&
binary.LittleEndian.Uint32(field[1:5]) == crc32.ChecksumIEEE([]byte(f.Name)) && utf8.Valid(field[5:]) {
return string(field[5:]), true
}
}
return "", false
}
func decode(f *zip.File, c codec) (string, error) {
name, declared := unicodeName(f)
if !declared {
name = f.Name
if c.encoding != nil {
var err error
name, err = c.encoding.NewDecoder().String(name)
if err != nil || strings.ContainsRune(name, utf8.RuneError) {
return "", fmt.Errorf("%w: cannot decode filename as %s", ErrEncoding, c.name)
}
}
}
if !utf8.ValidString(name) || strings.ContainsRune(name, 0) {
return "", fmt.Errorf("%w: invalid filename for %s", ErrEncoding, c.name)
}
return name, nil
}
// Inspect considers all entry names without decompressing file contents. Candidate
// previews prefer names lacking Unicode metadata so that users can compare them.
func Inspect(files []*zip.File) Preview {
result := Preview{Candidates: []Candidate{}}
bestScore, tied := 0, false
for _, c := range codecs {
candidate := Candidate{Encoding: c.name, Names: []string{}}
fallback := []string{}
score, valid := 0, true
for _, f := range files {
name, err := decode(f, c)
if err != nil {
valid = false
break
}
if len(fallback) < 5 {
fallback = append(fallback, name)
}
if _, declared := unicodeName(f); !declared && !isASCII(f.Name) {
if len(candidate.Names) < 5 {
candidate.Names = append(candidate.Names, name)
}
// The EUC-KR decoder also accepts CP949 extension bytes, including
// CP932 kana pairs. Do not use those bytes as Korean evidence.
if c.name != "euc-kr" || isEUCKR(f.Name) {
score += scriptEvidence(name, c.name)
}
}
}
if !valid {
continue
}
if len(candidate.Names) == 0 {
candidate.Names = fallback
}
result.Candidates = append(result.Candidates, candidate)
// Valid UTF-8 (including metadata-backed names) needs no legacy guess.
if c.name == "utf-8" {
result.Suggested = c.name
}
if score > bestScore {
bestScore, tied = score, false
if result.Suggested != "utf-8" {
result.Suggested = c.name
}
} else if score == bestScore && score > 0 {
tied = true
}
}
if result.Suggested != "utf-8" && (bestScore < 3 || tied) {
result.Suggested = ""
}
return result
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= utf8.RuneSelf {
return false
}
}
return true
}
// isEUCKR excludes CP949 extension sequences from automatic Korean suggestions.
func isEUCKR(raw string) bool {
for i := 0; i < len(raw); i++ {
if raw[i] < utf8.RuneSelf {
continue
}
if raw[i] < 0xa1 || raw[i] > 0xfe || i+1 >= len(raw) || raw[i+1] < 0xa1 || raw[i+1] > 0xfe {
return false
}
i++
}
return true
}
// Kana and Hangul give useful script evidence; Han alone does not distinguish
// Japanese, simplified Chinese and traditional Chinese. Half-width kana are not
// evidence: unrelated legacy bytes frequently decode to half-width kana.
func scriptEvidence(name, charset string) int {
score := 0
for _, r := range name {
if charset == "cp932" && r < 0xff00 && (unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r)) {
score++
}
if charset == "euc-kr" && unicode.Is(unicode.Hangul, r) {
score++
}
}
return score
}
// DecodeNames resolves the selection once for the entire archive. Explicit
// Unicode metadata always wins over the selected fallback encoding.
func DecodeNames(files []*zip.File, charset string) ([]string, error) {
if charset == "" {
charset = Inspect(files).Suggested
if charset == "" {
return nil, fmt.Errorf("%w: choose an encoding before extracting", ErrEncoding)
}
}
for _, c := range codecs {
if c.name != charset {
continue
}
names := make([]string, 0, len(files))
for _, f := range files {
name, err := decode(f, c)
if err != nil {
return nil, err
}
names = append(names, name)
}
return names, nil
}
return nil, fmt.Errorf("%w: unsupported encoding %s", ErrEncoding, charset)
}
@@ -0,0 +1,119 @@
package archiveencoding
import (
"archive/zip"
"encoding/binary"
"golang.org/x/text/encoding/japanese"
"hash/crc32"
"reflect"
"testing"
)
func entry(name string) *zip.File { return &zip.File{FileHeader: zip.FileHeader{Name: name}} }
func cp932(t *testing.T, name string) string {
t.Helper()
s, err := japanese.ShiftJIS.NewEncoder().String(name)
if err != nil {
t.Fatal(err)
}
return s
}
func TestJapaneseSuggestionMatchesDecoding(t *testing.T) {
t.Parallel()
want := []string{"日本語/テストソ001.txt", "日本語/テストソ002.txt"}
files := []*zip.File{entry(cp932(t, want[0])), entry(cp932(t, want[1]))}
preview := Inspect(files)
if preview.Suggested != "cp932" {
t.Fatalf("suggestion: %+v", preview)
}
names, err := DecodeNames(files, preview.Suggested)
if err != nil || !reflect.DeepEqual(names, want) {
t.Fatalf("names=%q err=%v", names, err)
}
for _, c := range preview.Candidates {
if c.Encoding == preview.Suggested && !reflect.DeepEqual(c.Names, names) {
t.Fatalf("preview mismatch: %q", c.Names)
}
}
}
func TestAmbiguousNamesRequireSelection(t *testing.T) {
t.Parallel()
files := []*zip.File{entry("caf\x82.txt")}
if p := Inspect(files); p.Suggested != "" {
t.Fatalf("ambiguous name was guessed: %+v", p)
}
if _, err := DecodeNames(files, ""); err == nil {
t.Fatal("expected explicit choice")
}
names, err := DecodeNames(files, "cp437")
if err != nil || names[0] != "café.txt" {
t.Fatalf("names=%q err=%v", names, err)
}
for _, c := range []string{"utf-8", "unknown"} {
if _, err = DecodeNames(files, c); err == nil {
t.Fatalf("invalid encoding accepted: %s", c)
}
}
}
func unicodeExtra(raw, name string) []byte {
payload := append([]byte{1, 0, 0, 0, 0}, []byte(name)...)
binary.LittleEndian.PutUint32(payload[1:], crc32.ChecksumIEEE([]byte(raw)))
extra := []byte{0x75, 0x70, 0, 0}
binary.LittleEndian.PutUint16(extra[2:], uint16(len(payload)))
return append(extra, payload...)
}
func TestUnicodeMetadataTakesPrecedence(t *testing.T) {
t.Parallel()
flagged := entry("日本語.txt")
flagged.Flags = 0x800
extra := entry("legacy.txt")
extra.Extra = unicodeExtra(extra.Name, "別の名前.txt")
names, err := DecodeNames([]*zip.File{flagged, extra}, "cp437")
if err != nil || !reflect.DeepEqual(names, []string{"日本語.txt", "別の名前.txt"}) {
t.Fatalf("names=%q err=%v", names, err)
}
extra.Extra[5] ^= 1
names, err = DecodeNames([]*zip.File{extra}, "cp437")
if err != nil || names[0] != "legacy.txt" {
t.Fatalf("names=%q err=%v", names, err)
}
}
func TestInvalidUnicodeMetadata(t *testing.T) {
t.Parallel()
for _, raw := range []string{"bad\xff.txt", "bad\x00.txt"} {
f := entry(raw)
f.Flags = 0x800
if _, err := DecodeNames([]*zip.File{f}, "cp932"); err == nil {
t.Fatalf("invalid declared UTF-8 accepted: %q", raw)
}
}
f := entry("ok.txt")
f.Extra = []byte{0x75, 0x70, 0xff, 0xff}
if _, err := DecodeNames([]*zip.File{f}, "utf-8"); err != nil {
t.Fatal(err)
}
}
func TestUTF8AndASCIIArchives(t *testing.T) {
t.Parallel()
for _, files := range [][]*zip.File{nil, {entry("readme.txt")}, {entry("日本語.txt")}} {
if p := Inspect(files); p.Suggested != "utf-8" {
t.Fatalf("expected UTF-8: %+v", p)
}
}
}
func TestPreviewBoundAndAllNamesValidated(t *testing.T) {
t.Parallel()
files := make([]*zip.File, 20)
for i := range files {
files[i] = entry("readme.txt")
}
files = append(files, entry("invalid\xff.txt"))
for _, c := range Inspect(files).Candidates {
if len(c.Names) > 5 {
t.Fatal("unbounded preview")
}
if c.Encoding == "utf-8" {
t.Fatal("entry beyond preview was not validated")
}
}
}
+7 -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,12 +40,17 @@ 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,
+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)
}
+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)
}
})
}
+241 -76
View File
@@ -1,7 +1,6 @@
package access
import (
"encoding/json"
"fmt"
"maps"
"path/filepath"
@@ -222,8 +221,14 @@ func accessRuleHasPayload(rule *AccessRule) bool {
// persistRuleSQLNL upserts or deletes one access_rules row to match in-memory state.
// Caller must hold s.mux (write lock). normalizedPath must match rule map keys (see getOrCreateRuleNL).
func (s *Storage) persistRuleSQLNL(sourcePath, normalizedPath string) {
if err := s.persistRuleSQLErrorNL(sourcePath, normalizedPath); err != nil {
logger.Warningf("%v", err)
}
}
func (s *Storage) persistRuleSQLErrorNL(sourcePath, normalizedPath string) error {
if s.sqlStore == nil {
return
return nil
}
var rule *AccessRule
if bySrc, ok := s.AllRules[sourcePath]; ok {
@@ -231,13 +236,30 @@ func (s *Storage) persistRuleSQLNL(sourcePath, normalizedPath string) {
}
if !accessRuleHasPayload(rule) {
if err := s.sqlStore.DeleteAccessRule(sourcePath, normalizedPath); err != nil {
logger.Warningf("access rules SQL delete (source=%s path=%s): %v", sourcePath, normalizedPath, err)
return fmt.Errorf("access rules SQL delete (source=%s path=%s): %w", sourcePath, normalizedPath, err)
}
return
return nil
}
if err := s.sqlStore.SaveAccessRule(sourcePath, normalizedPath, rule); err != nil {
logger.Warningf("access rules SQL save (source=%s path=%s): %v", sourcePath, normalizedPath, err)
return fmt.Errorf("access rules SQL save (source=%s path=%s): %w", sourcePath, normalizedPath, err)
}
return nil
}
func (s *Storage) restoreRuleSQLNL(key RuleKey, rule *AccessRule) error {
if s.sqlStore == nil {
return nil
}
if !accessRuleHasPayload(rule) {
if err := s.sqlStore.DeleteAccessRule(key.Source, key.Path); err != nil {
return fmt.Errorf("access rules SQL delete (source=%s path=%s): %w", key.Source, key.Path, err)
}
return nil
}
if err := s.sqlStore.SaveAccessRule(key.Source, key.Path, rule); err != nil {
return fmt.Errorf("access rules SQL save (source=%s path=%s): %w", key.Source, key.Path, err)
}
return nil
}
// RemoveRuleByPath removes a rule by normalized index path.
@@ -677,13 +699,8 @@ func (s *Storage) DeleteGroup(group string) error {
s.mux.Lock()
defer s.mux.Unlock()
previousMembers, existed := s.Groups[group]
// Snapshot the rules so they can be restored if the transaction fails.
rulesSnapshot, err := json.Marshal(s.AllRules)
if err != nil {
return fmt.Errorf("failed to snapshot access rules: %w", err)
}
delete(s.Groups, group)
touched := s.removeAllRulesForGroupNL(group)
touched, originals := s.removeAllRulesForGroupNL(group)
if !existed && len(touched) == 0 {
return nil
}
@@ -691,11 +708,11 @@ func (s *Storage) DeleteGroup(group string) error {
if existed {
s.Groups[group] = previousMembers
}
restored := make(SourceRuleMap)
if jsonErr := json.Unmarshal(rulesSnapshot, &restored); jsonErr != nil {
logger.Errorf("failed to restore access rules after group delete error: %v", jsonErr)
} else {
s.AllRules = restored
for key, rule := range originals {
if s.AllRules[key.Source] == nil {
s.AllRules[key.Source] = make(RuleMap)
}
s.AllRules[key.Source][key.Path] = rule
}
return err
}
@@ -748,7 +765,7 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
// Create a set of new groups for efficient lookup
newGroupsSet := make(StringSet, len(newGroups))
for _, g := range newGroups {
if g == "" {
if !IsUsableSyncedGroupName(g) {
continue
}
newGroupsSet[g] = struct{}{}
@@ -862,6 +879,143 @@ func (s *Storage) RemoveUserFromGroup(group, username string) error {
return nil
}
// RemoveUserFromAllGroups removes a username from every group's member list.
// Group rows themselves are kept, even when left empty. Used when a user is deleted.
func (s *Storage) RemoveUserFromAllGroups(username string) error {
s.mux.Lock()
defer s.mux.Unlock()
affected := make(map[string]StringSet)
for group, members := range s.Groups {
if _, ok := members[username]; ok {
affected[group] = cloneStringSet(members)
}
}
if len(affected) == 0 {
return nil
}
for group := range affected {
delete(s.Groups[group], username)
}
var persisted []string
for _, group := range slices.Sorted(maps.Keys(affected)) {
if err := s.persistGroupSQLNL(group); err != nil {
for _, g := range persisted {
if restoreErr := s.restoreGroupSQLNL(g, affected[g]); restoreErr != nil {
logger.Errorf("failed to restore group %q in sql after failure: %v", g, restoreErr)
}
}
for g, snap := range affected {
s.Groups[g] = snap
}
return err
}
persisted = append(persisted, group)
}
s.clearAllCaches()
return nil
}
// RenameUserInGroups replaces one username with another in every group member list.
// Used when a user's username changes so memberships follow the rename.
func (s *Storage) RenameUserInGroups(oldName, newName string) error {
s.mux.Lock()
defer s.mux.Unlock()
if oldName == newName {
return nil
}
affected := make(map[string]StringSet)
for group, members := range s.Groups {
if _, ok := members[oldName]; ok {
affected[group] = cloneStringSet(members)
}
}
if len(affected) == 0 {
return nil
}
for group := range affected {
delete(s.Groups[group], oldName)
s.Groups[group][newName] = struct{}{}
}
var persisted []string
for _, group := range slices.Sorted(maps.Keys(affected)) {
if err := s.persistGroupSQLNL(group); err != nil {
for _, g := range persisted {
if restoreErr := s.restoreGroupSQLNL(g, affected[g]); restoreErr != nil {
logger.Errorf("failed to restore group %q in sql after failure: %v", g, restoreErr)
}
}
for g, snap := range affected {
s.Groups[g] = snap
}
return err
}
persisted = append(persisted, group)
}
s.clearAllCaches()
return nil
}
// RenameUserInRules replaces one username with another in every allow and deny user list.
// Used when a user's username changes so path access rules follow the rename.
func (s *Storage) RenameUserInRules(oldName, newName string) error {
s.mux.Lock()
defer s.mux.Unlock()
if oldName == newName {
return nil
}
affected := make(map[RuleKey]*AccessRule)
var keys []RuleKey
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
_, inAllow := rule.Allow.Users[oldName]
_, inDeny := rule.Deny.Users[oldName]
if !inAllow && !inDeny {
continue
}
key := RuleKey{Source: sourcePath, Path: indexPath}
affected[key] = cloneAccessRule(rule)
if inAllow {
delete(rule.Allow.Users, oldName)
rule.Allow.Users[newName] = struct{}{}
}
if inDeny {
delete(rule.Deny.Users, oldName)
rule.Deny.Users[newName] = struct{}{}
}
keys = append(keys, key)
}
}
if len(keys) == 0 {
return nil
}
slices.SortFunc(keys, func(a, b RuleKey) int {
if c := strings.Compare(a.Source, b.Source); c != 0 {
return c
}
return strings.Compare(a.Path, b.Path)
})
var persisted []RuleKey
for _, key := range keys {
if err := s.persistRuleSQLErrorNL(key.Source, key.Path); err != nil {
for _, pk := range persisted {
if restoreErr := s.restoreRuleSQLNL(pk, affected[pk]); restoreErr != nil {
logger.Errorf("failed to restore access rule %q/%q in sql after failure: %v", pk.Source, pk.Path, restoreErr)
}
}
for key, snap := range affected {
if s.AllRules[key.Source] == nil {
s.AllRules[key.Source] = make(RuleMap)
}
s.AllRules[key.Source][key.Path] = snap
}
return err
}
persisted = append(persisted, key)
}
s.clearAllCaches()
return nil
}
// RemoveAllowUser removes a user from the allow list for a given source and index path.
func (s *Storage) RemoveAllowUser(sourcePath string, indexPath utils.IndexPath, username string) (bool, error) {
s.mux.Lock()
@@ -1008,93 +1162,104 @@ func (s *Storage) RemoveDenyAll(sourcePath string, indexPath utils.IndexPath) (b
}
// RemoveAllRulesForUser removes a user from all allow and deny lists.
// On a persistence failure the in-memory and already-persisted rules are restored and the error is returned.
func (s *Storage) RemoveAllRulesForUser(username string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
dirty := make(map[string]map[string]struct{})
touch := func(sp, ip string) {
if dirty[sp] == nil {
dirty[sp] = make(map[string]struct{})
}
dirty[sp][ip] = struct{}{}
}
affected := make(map[RuleKey]*AccessRule)
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
if _, exists := rule.Allow.Users[username]; exists {
delete(rule.Allow.Users, username)
touch(sourcePath, indexPath)
changed = true
_, inAllow := rule.Allow.Users[username]
_, inDeny := rule.Deny.Users[username]
if !inAllow && !inDeny {
continue
}
if _, exists := rule.Deny.Users[username]; exists {
delete(rule.Deny.Users, username)
touch(sourcePath, indexPath)
changed = true
}
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
affected[RuleKey{Source: sourcePath, Path: indexPath}] = cloneAccessRule(rule)
delete(rule.Allow.Users, username)
delete(rule.Deny.Users, username)
}
}
if len(affected) == 0 {
return nil
}
keys := slices.SortedFunc(maps.Keys(affected), func(a, b RuleKey) int {
if c := strings.Compare(a.Source, b.Source); c != 0 {
return c
}
return strings.Compare(a.Path, b.Path)
})
for _, key := range keys {
rule := s.AllRules[key.Source][key.Path]
if rule.DenyAll || len(rule.Allow.Users) > 0 || len(rule.Allow.Groups) > 0 || len(rule.Deny.Users) > 0 || len(rule.Deny.Groups) > 0 {
continue
}
delete(s.AllRules[key.Source], key.Path)
if len(s.AllRules[key.Source]) == 0 {
delete(s.AllRules, key.Source)
}
}
var persisted []RuleKey
for _, key := range keys {
if err := s.persistRuleSQLErrorNL(key.Source, key.Path); err != nil {
for _, pk := range persisted {
if restoreErr := s.restoreRuleSQLNL(pk, affected[pk]); restoreErr != nil {
logger.Errorf("failed to restore access rule %q/%q in sql after failure: %v", pk.Source, pk.Path, restoreErr)
}
touch(sourcePath, indexPath)
}
}
}
if changed {
s.clearAllCaches()
for sp, paths := range dirty {
for ip := range paths {
s.persistRuleSQLNL(sp, ip)
for k, snap := range affected {
if s.AllRules[k.Source] == nil {
s.AllRules[k.Source] = make(RuleMap)
}
s.AllRules[k.Source][k.Path] = snap
}
return err
}
persisted = append(persisted, key)
}
return nil
}
// RemoveAllRulesForGroup removes a group from all allow and deny lists.
func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
touched := s.removeAllRulesForGroupNL(groupname)
if len(touched) > 0 {
s.clearAllCaches()
for _, key := range touched {
s.persistRuleSQLNL(key.Source, key.Path)
}
}
s.clearAllCaches()
return nil
}
// removeAllRulesForGroupNL removes a group from every allow and deny list in memory and
// returns the rules it touched (rules left empty are deleted). It does not persist; the
// caller must hold s.mux and save the returned rules.
func (s *Storage) removeAllRulesForGroupNL(groupname string) []RuleKey {
// returns the rules it touched (rules left empty are deleted) plus a deep copy of each
// touched rule's previous state for rollback. It does not persist; the caller must hold
// s.mux and save the returned rules.
func (s *Storage) removeAllRulesForGroupNL(groupname string) ([]RuleKey, map[RuleKey]*AccessRule) {
var touched []RuleKey
originals := make(map[RuleKey]*AccessRule)
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
touchedRule := false
if _, exists := rule.Allow.Groups[groupname]; exists {
delete(rule.Allow.Groups, groupname)
touchedRule = true
}
if _, exists := rule.Deny.Groups[groupname]; exists {
delete(rule.Deny.Groups, groupname)
touchedRule = true
_, inAllow := rule.Allow.Groups[groupname]
_, inDeny := rule.Deny.Groups[groupname]
if !inAllow && !inDeny {
continue
}
key := RuleKey{Source: sourcePath, Path: indexPath}
originals[key] = cloneAccessRule(rule)
delete(rule.Allow.Groups, groupname)
delete(rule.Deny.Groups, groupname)
// A rule left with only DenyAll must stay, otherwise removing the group would open the path.
if !accessRuleHasPayload(rule) {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
touchedRule = true
}
if touchedRule {
touched = append(touched, RuleKey{Source: sourcePath, Path: indexPath})
}
touched = append(touched, key)
}
}
return touched
return touched, originals
}
func cloneAccessRule(rule *AccessRule) *AccessRule {
if rule == nil {
return nil
}
return &AccessRule{
DenyAll: rule.DenyAll,
Deny: RuleSet{Users: cloneStringSet(rule.Deny.Users), Groups: cloneStringSet(rule.Deny.Groups)},
Allow: RuleSet{Users: cloneStringSet(rule.Allow.Users), Groups: cloneStringSet(rule.Allow.Groups)},
}
}
// GetRulesForUser returns all rules for a specific user for a given sourcePath.
@@ -0,0 +1,79 @@
package access
import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// MaxGroupNameLength bounds names for manually created groups. Existing names
// (e.g. LDAP DNs synced from an IdP) may exceed it and are never rejected on read.
const MaxGroupNameLength = 128
// MinGroupNameLength bounds manually created group names. Existing records are
// never rejected so legacy single-character names stay usable.
const MinGroupNameLength = 2
// NormalizeGroupName trims and validates a group name supplied by an admin API.
// It is only used when manually creating or referencing a group; names that
// already exist in the database are always accepted on read, list, edit and
// delete paths so legacy records stay usable.
func NormalizeGroupName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("group name is required")
}
if utf8.RuneCountInString(name) < MinGroupNameLength && !isSingleLetterName(name) {
return "", fmt.Errorf("group name must be at least %d characters", MinGroupNameLength)
}
if utf8.RuneCountInString(name) > MaxGroupNameLength {
return "", fmt.Errorf("group name exceeds %d characters", MaxGroupNameLength)
}
for _, r := range name {
if unicode.IsControl(r) {
return "", fmt.Errorf("group name must not contain control characters")
}
}
return name, nil
}
// isSingleLetterName reports whether name is one non-ASCII letter (e.g. a CJK ideograph),
// which is a meaningful group name on its own.
func isSingleLetterName(name string) bool {
r, size := utf8.DecodeRuneInString(name)
return size == len(name) && r > unicode.MaxASCII && unicode.IsLetter(r)
}
// NormalizeMembers trims, dedupes and drops empty usernames while preserving order.
func NormalizeMembers(usernames []string) []string {
seen := make(map[string]struct{}, len(usernames))
out := make([]string, 0, len(usernames))
for _, name := range usernames {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
return out
}
// IsUsableSyncedGroupName reports whether a group name coming from an identity
// provider can be stored. Only the minimum needed to keep rows usable is
// rejected; everything else is kept so IdP naming conventions keep working.
func IsUsableSyncedGroupName(name string) bool {
if strings.TrimSpace(name) == "" {
return false
}
for _, r := range name {
if unicode.IsControl(r) {
return false
}
}
return true
}
@@ -0,0 +1,115 @@
package access_test
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
)
func TestNormalizeGroupName(t *testing.T) {
for _, tc := range []struct {
name string
in string
want string
wantErr bool
}{
{"trims", " editors ", "editors", false},
{"spaces inside", "cn=admins with spaces,ou=x", "cn=admins with spaces,ou=x", false},
{"empty", "", "", true},
{"blank", " ", "", true},
{"newline", "a\nb", "", true},
{"too short", "a", "", true},
{"single wide char", "界", "界", false},
{"two chars", "ab", "ab", false},
{"single CJK char", "组", "组", false},
{"single non-letter non-ascii", "€", "", true},
{"too long", string(make([]byte, access.MaxGroupNameLength+1)), "", true},
} {
got, err := access.NormalizeGroupName(tc.in)
if tc.wantErr {
if err == nil {
t.Fatalf("%s: expected error, got %q", tc.name, got)
}
continue
}
if err != nil || got != tc.want {
t.Fatalf("%s: got %q, err=%v", tc.name, got, err)
}
}
// Long names of valid characters fail on length, not on content.
if _, err := access.NormalizeGroupName(string(make([]byte, access.MaxGroupNameLength+1))); err == nil {
t.Fatal("expected too-long name to fail")
}
if got := access.NormalizeMembers([]string{" a ", "b", "", "a", " "}); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected members: %v", got)
}
}
func TestRemoveUserFromAllGroups(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("editors", []string{"alice", "bob"}); err != nil {
t.Fatal(err)
}
if err := s.SetGroupMembers("staff", []string{"alice"}); err != nil {
t.Fatal(err)
}
if err := s.RemoveUserFromAllGroups("alice"); err != nil {
t.Fatal(err)
}
if groups := s.GetUserGroups("alice"); len(groups) != 0 {
t.Fatalf("alice should be in no groups, got %v", groups)
}
if got := s.GetGroupMembers()["editors"]; len(got) != 1 || got[0] != "bob" {
t.Fatalf("expected [bob], got %v", got)
}
// The emptied group row still exists.
if _, ok := s.GetGroupMembers()["staff"]; !ok {
t.Fatal("empty group should remain")
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["staff"]["alice"]; ok {
t.Fatal("alice should be removed from staff in SQL")
}
// Removing a user who is in no group is a no-op.
if err := s.RemoveUserFromAllGroups("nobody"); err != nil {
t.Fatal(err)
}
}
func TestRenameUserInGroups(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("editors", []string{"alice", "bob"}); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInGroups("alice", "alicia"); err != nil {
t.Fatal(err)
}
got := s.GetGroupMembers()["editors"]
if len(got) != 2 || got[0] != "alicia" || got[1] != "bob" {
t.Fatalf("unexpected members: %v", got)
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["editors"]["alicia"]; !ok {
t.Fatal("alicia should be persisted in SQL")
}
if _, ok := sqlGroups["editors"]["alice"]; ok {
t.Fatal("alice should be removed in SQL")
}
// Renaming a non-member or same name is a no-op.
if err := s.RenameUserInGroups("nobody", "other"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInGroups("alicia", "alicia"); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,129 @@
package access_test
import (
"slices"
"testing"
)
func TestRenameUserInRules(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
src := "test_source"
if err := s.AllowUser(src, idxPath("/allowed"), "alice"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/denied"), "alice"); err != nil {
t.Fatal(err)
}
if err := s.AllowUser(src, idxPath("/both"), "alice"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/both"), "alice"); err != nil {
t.Fatal(err)
}
// New name already listed on another path (set semantics: old entry removed, new unchanged).
if err := s.AllowUser(src, idxPath("/existing"), "alicia"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInRules("alice", "alicia"); err != nil {
t.Fatal(err)
}
if rules := s.GetRulesForUser(src, "alice"); len(rules) != 0 {
t.Fatalf("expected no rules for old name alice, got %d", len(rules))
}
aliciaRules := s.GetRulesForUser(src, "alicia")
if len(aliciaRules) != 4 {
t.Fatalf("expected 4 paths for alicia, got %d", len(aliciaRules))
}
for _, path := range []string{"/allowed/", "/denied/", "/both/", "/existing/"} {
if _, ok := aliciaRules[path]; !ok {
t.Fatalf("missing rule at %q: %v", path, aliciaRules)
}
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
for _, rule := range sqlRules[src] {
if _, ok := rule.Allow.Users["alice"]; ok {
t.Fatal("alice should not appear in allow lists in SQL")
}
if _, ok := rule.Deny.Users["alice"]; ok {
t.Fatal("alice should not appear in deny lists in SQL")
}
}
if err := s.RenameUserInRules("nobody", "other"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInRules("alicia", "alicia"); err != nil {
t.Fatal(err)
}
}
func TestRemoveAllRulesForUserOnDelete(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
src := "test_source"
if err := s.AllowUser(src, idxPath("/a"), "bob"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/b"), "bob"); err != nil {
t.Fatal(err)
}
if err := s.RemoveAllRulesForUser("bob"); err != nil {
t.Fatal(err)
}
if rules := s.GetRulesForUser(src, "bob"); len(rules) != 0 {
t.Fatalf("expected no in-memory rules for bob, got %d", len(rules))
}
all, err := s.GetAllRules(src)
if err != nil {
t.Fatal(err)
}
if len(all) != 0 {
t.Fatalf("expected no rules left on source, got %d", len(all))
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
if len(sqlRules[src]) != 0 {
t.Fatalf("expected no SQL rules for source, got %v", sqlRules[src])
}
}
func TestRenameUserInRulesBothAllowAndDenySamePath(t *testing.T) {
setupTestSources()
s, _, _ := createTestStorageWithSQL(t)
src := "test_source"
if err := s.AllowUser(src, idxPath("/shared"), "old"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/shared"), "old"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInRules("old", "new"); err != nil {
t.Fatal(err)
}
rules, ok := s.GetFrontendRules(src, idxPath("/shared"))
if !ok {
t.Fatal("expected rule at /shared")
}
if !slices.Contains(rules.Allow.Users, "new") || slices.Contains(rules.Allow.Users, "old") {
t.Fatalf("allow users: %v", rules.Allow.Users)
}
if !slices.Contains(rules.Deny.Users, "new") || slices.Contains(rules.Deny.Users, "old") {
t.Fatalf("deny users: %v", rules.Deny.Users)
}
}
@@ -21,6 +21,9 @@ type IndexInfo struct {
UsedAsIndexed uint64 `json:"used"`
UsedDisk uint64 `json:"usedAlt"`
DiskTotal uint64 `json:"total"`
// Root-filesystem-only view (no nested mount aggregation)
UsedDiskRoot uint64 `json:"usedAltRoot"`
DiskTotalRoot uint64 `json:"totalRoot"`
// Scanner information - map of scanner path to scanner stats
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
@@ -0,0 +1,200 @@
package sql
import (
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
// TestCreatedTime_InsertAndReadBack checks that an inserted Created value is returned unchanged.
func TestCreatedTime_InsertAndReadBack(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_set", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
created := time.Unix(1700000000, 0)
info := &iteminfo.FileInfo{
Path: "/a.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "a.txt",
Size: 12,
Created: &created,
ModTime: time.Unix(1, 0),
Type: "text/plain",
},
}
if insertErr := db.InsertItem("src1", "/a.txt", info); insertErr != nil {
t.Fatal(insertErr)
}
got, err := db.GetItem("src1", "/a.txt")
if err != nil {
t.Fatal(err)
}
if got.Created == nil {
t.Fatal("expected non-nil Created")
}
if !got.Created.Equal(created.Truncate(time.Second)) {
t.Errorf("Created = %v, want %v", got.Created, created.Truncate(time.Second))
}
}
// TestCreatedTime_NilStaysNil checks that an item inserted without Created reads back with nil.
func TestCreatedTime_NilStaysNil(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_nil", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
info := &iteminfo.FileInfo{
Path: "/b.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "b.txt",
Size: 12,
ModTime: time.Unix(1, 0),
Type: "text/plain",
},
}
if insertErr := db.InsertItem("src1", "/b.txt", info); insertErr != nil {
t.Fatal(insertErr)
}
got, err := db.GetItem("src1", "/b.txt")
if err != nil {
t.Fatal(err)
}
if got.Created != nil {
t.Errorf("expected nil Created, got %v", got.Created)
}
}
// TestCreatedTime_UpsertSetsCreated checks that an upsert fills in a previously nil Created.
func TestCreatedTime_UpsertSetsCreated(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_upsert", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
info := &iteminfo.FileInfo{
Path: "/c.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "c.txt",
Size: 12,
ModTime: time.Unix(1, 0),
Type: "text/plain",
},
}
if insertErr := db.InsertItem("src1", "/c.txt", info); insertErr != nil {
t.Fatal(insertErr)
}
created := time.Unix(1700000000, 0)
info.Created = &created
if updateErr := db.InsertItem("src1", "/c.txt", info); updateErr != nil {
t.Fatal(updateErr)
}
got, err := db.GetItem("src1", "/c.txt")
if err != nil {
t.Fatal(err)
}
if got.Created == nil {
t.Fatal("expected non-nil Created after upsert")
}
if !got.Created.Equal(created.Truncate(time.Second)) {
t.Errorf("Created = %v, want %v", got.Created, created.Truncate(time.Second))
}
}
// TestCreatedTime_MigrationAddsColumn checks that CreateIndexTable adds created_time to an
// old-schema table and can be run repeatedly.
func TestCreatedTime_MigrationAddsColumn(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_migration", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`DROP TABLE index_items`); err != nil {
t.Fatal(err)
}
oldSchema := `
CREATE TABLE index_items (
source TEXT NOT NULL,
path TEXT NOT NULL,
parent_path TEXT NOT NULL,
name TEXT NOT NULL,
size INTEGER NOT NULL,
mod_time INTEGER NOT NULL,
type TEXT NOT NULL,
is_dir BOOLEAN NOT NULL,
is_hidden BOOLEAN NOT NULL,
has_preview BOOLEAN NOT NULL,
last_updated INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (source, path)
)`
if _, err := db.Exec(oldSchema); err != nil {
t.Fatal(err)
}
if err := db.CreateIndexTable(); err != nil {
t.Fatal(err)
}
if !hasCreatedTimeColumn(t, db) {
t.Fatal("expected created_time column after migration")
}
// Running again must not error (repeatable migration).
if err := db.CreateIndexTable(); err != nil {
t.Fatal(err)
}
if !hasCreatedTimeColumn(t, db) {
t.Fatal("expected created_time column after second CreateIndexTable call")
}
}
// hasCreatedTimeColumn reports whether index_items currently has a created_time column.
func hasCreatedTimeColumn(t *testing.T, db *IndexDB) bool {
t.Helper()
rows, err := db.Query(`PRAGMA table_info(index_items)`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var cid, notNull, pk int
var name, columnType string
var defaultValue interface{}
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &pk); err != nil {
t.Fatal(err)
}
if name == "created_time" {
return true
}
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return false
}
+69 -13
View File
@@ -245,6 +245,7 @@ func (db *IndexDB) CreateIndexTable() error {
parent_path TEXT NOT NULL,
name TEXT NOT NULL,
size INTEGER NOT NULL,
created_time INTEGER NOT NULL DEFAULT 0,
mod_time INTEGER NOT NULL,
type TEXT NOT NULL,
is_dir BOOLEAN NOT NULL,
@@ -259,17 +260,63 @@ func (db *IndexDB) CreateIndexTable() error {
CREATE INDEX IF NOT EXISTS idx_last_updated ON index_items(source, last_updated);
`
_, err := db.Exec(query)
if err != nil {
return err
}
return db.ensureCreatedTimeColumn()
}
// ensureCreatedTimeColumn adds the created_time column to index_items when it is
// missing, so databases created before the column existed are upgraded in place.
func (db *IndexDB) ensureCreatedTimeColumn() error {
rows, err := db.Query(`PRAGMA table_info(index_items)`)
if err != nil {
return err
}
found := false
for rows.Next() {
var cid, notNull, pk int
var name, columnType string
var defaultValue interface{}
scanErr := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &pk)
if scanErr != nil {
rows.Close()
return scanErr
}
if name == "created_time" {
found = true
}
}
if rowsErr := rows.Err(); rowsErr != nil {
rows.Close()
return rowsErr
}
rows.Close()
if found {
return nil
}
_, err = db.Exec(`ALTER TABLE index_items ADD COLUMN created_time INTEGER NOT NULL DEFAULT 0`)
return err
}
// createdUnix converts a creation time to unix seconds for storage, returning 0
// when the time is nil or zero (stored as "unavailable").
func createdUnix(created *time.Time) int64 {
if created == nil || created.IsZero() {
return 0
}
return created.Unix()
}
func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) error {
query := `
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO index_items (source, path, parent_path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source, path) DO UPDATE SET
parent_path = excluded.parent_path,
name = excluded.name,
size = excluded.size,
created_time = excluded.created_time,
mod_time = excluded.mod_time,
type = excluded.type,
is_dir = excluded.is_dir,
@@ -284,6 +331,7 @@ func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) erro
parentPath,
info.Name,
info.Size,
createdUnix(info.Created),
info.ModTime.Unix(),
info.Type,
info.Type == "directory",
@@ -365,12 +413,13 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
}()
stmt, err := tx.Prepare(`
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO index_items (source, path, parent_path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source, path) DO UPDATE SET
parent_path = excluded.parent_path,
name = excluded.name,
size = excluded.size,
created_time = excluded.created_time,
mod_time = excluded.mod_time,
type = excluded.type,
is_dir = excluded.is_dir,
@@ -396,6 +445,7 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
parentPath,
info.Name,
info.Size,
createdUnix(info.Created),
info.ModTime.Unix(),
info.Type,
info.Type == "directory",
@@ -428,7 +478,7 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
// Busy/locked errors are returned so callers can fall back to the filesystem.
func (db *IndexDB) GetItem(source, path string) (*iteminfo.FileInfo, error) {
query := `
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items WHERE source = ? AND path = ?
`
row := db.QueryRow(query, source, path)
@@ -456,7 +506,7 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
}
query := fmt.Sprintf(`
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items WHERE source = ? AND path IN (%s)
`, strings.Join(placeholders, ","))
@@ -481,7 +531,7 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
// GetDirectoryChildren retrieves all children of a directory for a specific source.
func (db *IndexDB) GetDirectoryChildren(source, dirPath string) ([]*iteminfo.FileInfo, error) {
query := `
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items WHERE source = ? AND parent_path = ?
ORDER BY is_dir DESC, name ASC
`
@@ -927,7 +977,7 @@ func (db *IndexDB) GetFilesForMultipleSizes(source string, sizes []int64, pathPr
if pathPrefix != "" {
nextPrefix := getNextPathPrefix(pathPrefix)
query = fmt.Sprintf(`
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size IN (%s) AND is_dir = 0 AND path >= ? AND path < ?
ORDER BY size, type, name
@@ -935,7 +985,7 @@ func (db *IndexDB) GetFilesForMultipleSizes(source string, sizes []int64, pathPr
args = append(args, pathPrefix, nextPrefix)
} else {
query = fmt.Sprintf(`
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size IN (%s) AND is_dir = 0
ORDER BY size, type, name
@@ -1095,7 +1145,7 @@ func (db *IndexDB) GetFilesBySizeAndType(source string, size int64, fileType str
if pathPrefix != "" {
nextPrefix := getNextPathPrefix(pathPrefix)
query = `
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size = ? AND type = ? AND is_dir = 0 AND path >= ? AND path < ?
ORDER BY name
@@ -1103,7 +1153,7 @@ func (db *IndexDB) GetFilesBySizeAndType(source string, size int64, fileType str
args = []interface{}{source, size, fileType, pathPrefix, nextPrefix}
} else {
query = `
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size = ? AND type = ? AND is_dir = 0
ORDER BY name
@@ -1137,7 +1187,7 @@ func (db *IndexDB) GetFilesBySize(source string, size int64, pathPrefix string)
if pathPrefix != "" {
nextPrefix := getNextPathPrefix(pathPrefix)
query = `
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size = ? AND is_dir = 0 AND path >= ? AND path < ?
ORDER BY name
@@ -1145,7 +1195,7 @@ func (db *IndexDB) GetFilesBySize(source string, size int64, pathPrefix string)
args = []interface{}{source, size, pathPrefix, nextPrefix}
} else {
query = `
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size = ? AND is_dir = 0
ORDER BY name
@@ -1260,6 +1310,7 @@ func getParentPath(path string) string {
func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileInfo, error) {
var info iteminfo.FileInfo
var createdTime int64
var modTime int64
var isDir bool
@@ -1267,6 +1318,7 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
&info.Path,
&info.Name,
&info.Size,
&createdTime,
&modTime,
&info.Type,
&isDir,
@@ -1276,6 +1328,10 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
if err != nil {
return nil, err
}
if createdTime > 0 {
t := time.Unix(createdTime, 0)
info.Created = &t
}
info.ModTime = time.Unix(modTime, 0)
info.IsDir = isDir
return &info, nil
+13 -12
View File
@@ -8,14 +8,15 @@ import (
// SearchItem represents a single search result row from the database
type SearchItem struct {
Source string
Path string
Name string
Size int64
ModTime int64
Type string
IsDir bool
HasPreview bool
Source string
Path string
Name string
Size int64
CreatedTime int64
ModTime int64
Type string
IsDir bool
HasPreview bool
}
// appendNameGlobSQL adds AND (name GLOB ? OR ...) or AND (name GLOB ? AND ...) when joinAnd is true.
@@ -44,8 +45,8 @@ func appendNameGlobSQL(query string, args []interface{}, patterns []string, join
// Returns rows that can be iterated to scan search results.
func (db *IndexDB) SearchItems(source string, scope string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd, caseExact bool) (*sql.Rows, error) {
query := `
SELECT path, name, size, mod_time, type, is_dir, has_preview
FROM index_items
SELECT path, name, size, created_time, mod_time, type, is_dir, has_preview
FROM index_items
WHERE source = ?
`
args := []interface{}{source}
@@ -77,8 +78,8 @@ func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[str
}
query := `
SELECT source, path, name, size, mod_time, type, is_dir, has_preview
FROM index_items
SELECT source, path, name, size, created_time, mod_time, type, is_dir, has_preview
FROM index_items
`
args := []interface{}{}
whereClauses := []string{}
@@ -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")
}
@@ -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)
}
}
}
+22 -13
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
}
@@ -173,17 +180,18 @@ type Preview struct {
// FrontendUser holds fields safe to return from user APIs (embedded on User).
type FrontendUser struct {
NonAdminEditable
DisableSettings bool `json:"disableSettings"`
Username string `json:"username"`
FrontendScopes []FrontendScope `json:"scopes"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"`
ShowFirstLogin bool `json:"showFirstLogin"`
EffectiveToolAccess map[string]bool `json:"effectiveToolAccess,omitempty"`
Perm Permissions `json:"perm,omitzero"`
DisableSettings bool `json:"disableSettings"`
Username string `json:"username"`
FrontendScopes []FrontendScope `json:"scopes"`
LockPassword bool `json:"lockPassword"`
RequirePasswordChange bool `json:"requirePasswordChange"`
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"`
ShowFirstLogin bool `json:"showFirstLogin"`
EffectiveToolAccess map[string]bool `json:"effectiveToolAccess,omitempty"`
Perm Permissions `json:"perm,omitzero"`
}
// PinnedItems maps source filesystem path -> index directory path -> pinned item names.
@@ -234,7 +242,6 @@ type BackendScope struct {
// json tags must match variable name with smaller case first letter
type NonAdminEditable struct {
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
@@ -263,6 +270,8 @@ type NonAdminEditable struct {
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
ShowTypeColumn bool `json:"showTypeColumn"` // show the Type column in list and compact views
ShowCreationDateColumn bool `json:"showCreationDateColumn"` // show the Creation date column in list and compact views
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
+2 -1
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")
+41
View File
@@ -2,6 +2,7 @@ package state
import (
"fmt"
"slices"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
@@ -102,6 +103,46 @@ func SyncUserGroups(username string, newGroups []string) error {
return accessDb.SyncUserGroups(username, newGroups)
}
// GroupExists reports whether a group name is already registered.
func GroupExists(groupname string) bool {
if accessDb == nil {
return false
}
return slices.Contains(accessDb.GetAllGroups(), groupname)
}
// RemoveUserFromAllGroups removes a username from every group's member list.
func RemoveUserFromAllGroups(username string) error {
if accessDb == nil {
return nil
}
return accessDb.RemoveUserFromAllGroups(username)
}
// RenameUserInGroups replaces one username with another in all group member lists.
func RenameUserInGroups(oldName, newName string) error {
if accessDb == nil {
return nil
}
return accessDb.RenameUserInGroups(oldName, newName)
}
// RenameUserInRules replaces one username with another in all path access allow/deny lists.
func RenameUserInRules(oldName, newName string) error {
if accessDb == nil {
return nil
}
return accessDb.RenameUserInRules(oldName, newName)
}
// RemoveAllRulesForUser removes a username from every path access allow and deny list.
func RemoveAllRulesForUser(username string) error {
if accessDb == nil {
return nil
}
return accessDb.RemoveAllRulesForUser(username)
}
// SetAccessSQLStoreForTest replaces the SQL persister used by access control (tests only).
func SetAccessSQLStoreForTest(store access.SQLPersister) {
if accessDb != nil {
@@ -1,25 +1,24 @@
package state
import (
"crypto/rand"
"fmt"
"math/big"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
const bootstrapAdminPasswordHexBytes = 6 // 12 hex characters
// bootstrapDefaultAdminPassword returns the plaintext password for initial admin creation.
// Uses auth.methods.password.adminPassword (backfilled from auth.adminPassword when unset).
// Blank or "admin" is ignored and a random password is generated and logged once.
// When config adminPassword is blank or "admin", a random speakable password is generated and logged once.
func bootstrapDefaultAdminPassword(username string) (plaintext string, generated bool, err error) {
cfg := settings.PasswordAdminPassword()
if cfg != "" && cfg != "admin" {
return cfg, false, nil
}
plaintext, err = utils.RandomHex(bootstrapAdminPasswordHexBytes)
plaintext, err = generateSpeakableBootstrapPassword()
if err != nil {
return "", false, fmt.Errorf("generate bootstrap admin password: %w", err)
}
@@ -30,3 +29,37 @@ func bootstrapDefaultAdminPassword(username string) (plaintext string, generated
)
return plaintext, true, nil
}
func generateSpeakableBootstrapPassword() (string, error) {
if len(bootstrapWords) == 0 {
return "", fmt.Errorf("bootstrap word list is empty")
}
wordIdx, err := rand.Int(rand.Reader, big.NewInt(int64(len(bootstrapWords))))
if err != nil {
return "", err
}
word := bootstrapWords[wordIdx.Int64()]
primary, err := randomSpeakableCode(bootstrapSpeakablePrimaryCodeLen)
if err != nil {
return "", err
}
secondary, err := randomSpeakableCode(bootstrapSpeakableSecondaryCodeLen)
if err != nil {
return "", err
}
return fmt.Sprintf("%s-%s-%s", word, string(primary), string(secondary)), nil
}
func randomSpeakableCode(n int) ([]byte, error) {
codeRunes := make([]byte, n)
charsetLen := big.NewInt(int64(len(bootstrapSpeakableCharset)))
for i := range codeRunes {
idx, err := rand.Int(rand.Reader, charsetLen)
if err != nil {
return nil, err
}
codeRunes[i] = bootstrapSpeakableCharset[idx.Int64()]
}
return codeRunes, nil
}
@@ -1,12 +1,19 @@
package state
import (
"regexp"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestBootstrapWordsCount(t *testing.T) {
if len(bootstrapWords) != 100 {
t.Fatalf("bootstrapWords len = %d, want 100", len(bootstrapWords))
}
}
func TestBootstrapDefaultAdminPasswordUsesNestedConfigWhenSet(t *testing.T) {
origNested := settings.Config.Auth.Methods.PasswordAuth.AdminPassword
origLegacy := settings.Config.Auth.AdminPassword
@@ -51,6 +58,8 @@ func TestBootstrapDefaultAdminPasswordUsesTopLevelWhenNestedUnset(t *testing.T)
}
}
var speakableBootstrapPasswordPattern = regexp.MustCompile(`^[a-z]+-[abcdefghjkmnpqrstuvwxyz23456789]{5}-[abcdefghjkmnpqrstuvwxyz23456789]{2}$`)
func TestBootstrapDefaultAdminPasswordGeneratesWhenDefault(t *testing.T) {
origNested := settings.Config.Auth.Methods.PasswordAuth.AdminPassword
origLegacy := settings.Config.Auth.AdminPassword
@@ -72,11 +81,31 @@ func TestBootstrapDefaultAdminPasswordGeneratesWhenDefault(t *testing.T) {
if plain == "" || plain == "admin" {
t.Fatalf("cfg=%q: unexpected password %q", cfg, plain)
}
if len(plain) != bootstrapAdminPasswordHexBytes*2 {
t.Fatalf("cfg=%q: password length %d, want %d", cfg, len(plain), bootstrapAdminPasswordHexBytes*2)
if !speakableBootstrapPasswordPattern.MatchString(plain) {
t.Fatalf("cfg=%q: password %q does not match speakable pattern", cfg, plain)
}
if strings.Trim(plain, "0123456789abcdef") != "" {
t.Fatalf("cfg=%q: expected hex password, got %q", cfg, plain)
word := strings.Split(plain, "-")[0]
found := false
for _, w := range bootstrapWords {
if w == word {
found = true
break
}
}
if !found {
t.Fatalf("cfg=%q: word %q not in bootstrap word list", cfg, word)
}
}
}
func TestGenerateSpeakableBootstrapPassword(t *testing.T) {
for i := 0; i < 20; i++ {
plain, err := generateSpeakableBootstrapPassword()
if err != nil {
t.Fatal(err)
}
if !speakableBootstrapPasswordPattern.MatchString(plain) {
t.Fatalf("password %q does not match speakable pattern", plain)
}
}
}
@@ -0,0 +1,23 @@
package state
// bootstrapWords is a fixed list of short, common English nouns for one-time bootstrap passwords.
// Words are chosen to be easy to say, spell, and type (including for non-native English speakers).
var bootstrapWords = []string{
"apple", "ball", "barn", "bean", "bed", "belt", "bike", "bird", "boat", "book",
"boot", "bowl", "box", "bus", "cake", "camp", "cart", "cat", "clock", "cloud",
"coat", "coin", "corn", "cup", "desk", "dog", "door", "duck", "egg", "farm",
"fish", "flag", "food", "foot", "game", "gift", "goat", "gold", "grass", "hand",
"hat", "hill", "home", "horse", "house", "ice", "jar", "key", "kid", "lake",
"lamp", "land", "leaf", "leg", "light", "line", "lion", "list", "mail", "map",
"milk", "moon", "mouse", "nest", "net", "night", "nose", "note", "page", "park",
"path", "pen", "pet", "pie", "pig", "pin", "pipe", "plan", "plant", "pot",
"rain", "red", "ring", "road", "rock", "room", "rope", "rose", "sand", "sea",
"ship", "shoe", "shop", "sky", "snow", "sock", "star", "stone", "sun", "tree",
}
// bootstrapSpeakableCharset excludes ambiguous characters for read-aloud (no 0/o, 1/i/l).
const bootstrapSpeakableCharset = "abcdefghjkmnpqrstuvwxyz23456789"
// bootstrapSpeakablePrimaryCodeLen and bootstrapSpeakableSecondaryCodeLen form the code after the word (e.g. word-xxxxx-xx).
const bootstrapSpeakablePrimaryCodeLen = 5
const bootstrapSpeakableSecondaryCodeLen = 2
+4 -1
View File
@@ -52,10 +52,13 @@ func QuickSetup() error {
logger.Debugf("Creating user as admin: %v", user.Username)
plainPassword, _, err := bootstrapDefaultAdminPassword(user.Username)
plainPassword, generated, err := bootstrapDefaultAdminPassword(user.Username)
if err != nil {
return err
}
if generated {
user.RequirePasswordChange = true
}
hashedPassword, hashErr := utils.HashPwd(plainPassword)
if hashErr != nil {
+66 -3
View File
@@ -6,14 +6,15 @@ import (
"strings"
"github.com/google/uuid"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/toolaccess"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
// User operations
@@ -192,6 +193,9 @@ func CreateUser(user *users.User, plaintextPassword string) error {
}
// Hash password if provided
if plaintextPassword != "" {
if err := settings.ValidatePasswordPolicy(plaintextPassword); err != nil {
return err
}
hashedPassword, err := utils.HashPwd(plaintextPassword)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
@@ -205,7 +209,11 @@ func CreateUser(user *users.User, plaintextPassword string) error {
}
// If still no BackendScopes (omitted or invalid API names), same defaults as ApplyUserDefaults.
preserveRequirePasswordChange := user.RequirePasswordChange
applyUserSettingsDefaults(user)
if preserveRequirePasswordChange {
user.RequirePasswordChange = true
}
defaults := EffectiveUserDefaults()
enforced := EffectiveEnforced()
settings.ApplyEnforcedDefaultsFrom(user, defaults, enforced)
@@ -296,6 +304,9 @@ func UpdateUser(user *users.User, plaintextPassword string, fields ...string) er
if plaintextPassword == "" {
return fmt.Errorf("password field requires a non-empty plaintext password")
}
if err := settings.ValidatePasswordPolicy(plaintextPassword); err != nil {
return err
}
hashedPassword, hashErr := utils.HashPwd(plaintextPassword)
if hashErr != nil {
return fmt.Errorf("failed to hash password: %w", hashErr)
@@ -408,6 +419,11 @@ func commitUserUpdate(existingUser, storedSnapshot *users.User, sourceDefaults u
if err := sqlDb.UpdateUserUsername(oldUsername, existingUser); err != nil {
return err
}
if accessDb != nil {
if err := renameUserInAccess(oldUsername, existingUser); err != nil {
return err
}
}
if oldUserID != 0 && oldUserID != existingUser.ID {
userRecordCache.Delete(userCacheKeyID(oldUserID))
}
@@ -431,6 +447,31 @@ func commitUserUpdate(existingUser, storedSnapshot *users.User, sourceDefaults u
return nil
}
// renameUserInAccess renames the user in group memberships and path rules after the SQL rename
// has been written. On failure every earlier step is reverted (keeping the other updated fields)
// so the stores never disagree about which username owns the memberships.
func renameUserInAccess(oldUsername string, updated *users.User) error {
revertSQL := func() {
reverted := *updated
reverted.Username = oldUsername
if err := sqlDb.UpdateUser(&reverted); err != nil {
logger.Errorf("failed to revert username %q to %q after rename failure: %v", updated.Username, oldUsername, err)
}
}
if err := accessDb.RenameUserInGroups(oldUsername, updated.Username); err != nil {
revertSQL()
return fmt.Errorf("failed to rename user in groups: %w", err)
}
if err := accessDb.RenameUserInRules(oldUsername, updated.Username); err != nil {
if revErr := accessDb.RenameUserInGroups(updated.Username, oldUsername); revErr != nil {
logger.Errorf("failed to revert group memberships for %q after rename failure: %v", oldUsername, revErr)
}
revertSQL()
return fmt.Errorf("failed to rename user in access rules: %w", err)
}
return nil
}
func removedScopeQuotaIDs(before, after *users.User) []string {
oldByPath := map[string]string{}
for _, bs := range before.BackendScopes {
@@ -577,6 +618,9 @@ func DeleteUser(id uint64) error {
return fmt.Errorf("user not found in state")
}
if err := removeUserAccess(user.Username); err != nil {
return err
}
if err := sqlDb.DeleteUserByID(id); err != nil {
return err
}
@@ -601,6 +645,9 @@ func DeleteUserByUsername(username string) error {
}
uid := user.ID
if err := removeUserAccess(username); err != nil {
return err
}
if err := sqlDb.DeleteUserByUsername(username); err != nil {
return err
}
@@ -610,7 +657,23 @@ func DeleteUserByUsername(username string) error {
if accessDb != nil && uid != 0 {
_ = accessDb.RemoveHashedTokensForUser(uid)
}
return nil
}
// removeUserAccess strips a username from group memberships and path rules before the user row is
// deleted. Each step rolls itself back on failure and any error aborts the delete, so a reused
// username can never inherit leftovers. The failure mode is fail-safe: if the later SQL delete
// fails the user only loses access, and a retry is idempotent.
func removeUserAccess(username string) error {
if accessDb == nil {
return nil
}
if err := accessDb.RemoveUserFromAllGroups(username); err != nil {
return fmt.Errorf("failed to remove user from groups: %w", err)
}
if err := accessDb.RemoveAllRulesForUser(username); err != nil {
return fmt.Errorf("failed to remove user from access rules: %w", err)
}
return nil
}
+7 -2
View File
@@ -16,8 +16,13 @@ func prepareFrontendLink(link users.SidebarLink) (users.SidebarLink, bool) {
}
if full, ok := settings.Config.Server.SourceMap[source.Path]; ok {
category := users.NormalizeSidebarLinkCategory(link.Category)
if full.Config.ResolvedRules.IndexingDisabled && category == string(users.SidebarLinkSource) {
link.Category = string(users.SidebarLinkSourceAlt)
if full.Config.ResolvedRules.IndexingDisabled &&
users.BaseSidebarLinkCategory(category) == string(users.SidebarLinkSource) {
if users.SidebarLinkDiskScopeRoot(category) {
link.Category = string(users.SidebarLinkSourceAltRoot)
} else {
link.Category = string(users.SidebarLinkSourceAlt)
}
}
}
link.SourceName = source.Name
+126 -17
View File
@@ -6,8 +6,9 @@ import (
"net/http"
"strings"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
@@ -130,6 +131,7 @@ func accessPostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err)
}
body.Value = strings.TrimSpace(body.Value)
if indexPath == "" || body.RuleCategory == "" || (body.RuleCategory != "all" && body.Value == "") {
return http.StatusBadRequest, fmt.Errorf("path, ruleCategory, and value are required, unless ruleCategory is 'all'")
}
@@ -145,6 +147,9 @@ func accessPostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return http.StatusInternalServerError, fmt.Errorf("failed to look up user: %w", err)
}
}
if body.RuleCategory == "group" && !state.GroupExists(body.Value) {
return http.StatusBadRequest, fmt.Errorf("group not found: %s", body.Value)
}
if body.Allow {
switch body.RuleCategory {
case "user":
@@ -167,6 +172,9 @@ func accessPostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
}
}
if err != nil {
if err == errors.ErrExist {
return http.StatusConflict, fmt.Errorf("rule already contains this entry")
}
logger.Errorf("failed to add or update rule: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to add or update rule: %w", err)
}
@@ -196,12 +204,12 @@ func accessDeleteHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
indexPath := r.URL.Query().Get("path")
index := indexing.GetIndex(sourceName)
if index == nil {
return 500, fmt.Errorf("source not found: %s", sourceName)
return http.StatusBadRequest, fmt.Errorf("source not found: %s", sourceName)
}
ruleType := r.URL.Query().Get("ruleType")
ruleCategory := r.URL.Query().Get("ruleCategory")
value := r.URL.Query().Get("value")
value := strings.TrimSpace(r.URL.Query().Get("value"))
cascade := r.URL.Query().Get("cascade") == "true"
allow := ruleType == "allow"
@@ -254,6 +262,8 @@ func accessDeleteHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
found, err = state.RemoveAllowUser(index.Path, parsedPath, value)
case "group":
found, err = state.RemoveAllowGroup(index.Path, parsedPath, value)
default:
return http.StatusBadRequest, fmt.Errorf("invalid ruleCategory for allow list: must be 'user' or 'group'")
}
} else {
switch ruleCategory {
@@ -263,6 +273,8 @@ func accessDeleteHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
found, err = state.RemoveDenyGroup(index.Path, parsedPath, value)
case "all":
found, err = state.RemoveDenyAll(index.Path, parsedPath)
default:
return http.StatusBadRequest, fmt.Errorf("invalid ruleCategory: must be 'user', 'group', or 'all'")
}
}
if err != nil {
@@ -294,6 +306,12 @@ func groupGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, e
}
user := r.URL.Query().Get("user")
if user != "" {
if _, err := state.GetUserByUsername(user); err != nil {
if err == errors.ErrNotExist {
return http.StatusNotFound, fmt.Errorf("user not found: %s", user)
}
return http.StatusInternalServerError, fmt.Errorf("failed to look up user: %w", err)
}
groups := state.GetUserGroups(user)
return RenderJSON(w, r, &GroupListResponse{Groups: groups})
}
@@ -321,10 +339,27 @@ func groupPostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
if !d.User.Permissions.Admin {
return http.StatusForbidden, nil
}
group := r.URL.Query().Get("group")
user := r.URL.Query().Get("user")
err := state.AddUserToGroup(group, user)
if err != nil {
group := strings.TrimSpace(r.URL.Query().Get("group"))
if group == "" {
return http.StatusBadRequest, fmt.Errorf("group is required")
}
// Strict naming applies only to newly created groups; existing names always work.
if !state.GroupExists(group) {
if _, err := access.NormalizeGroupName(group); err != nil {
return http.StatusBadRequest, err
}
}
user := strings.TrimSpace(r.URL.Query().Get("user"))
if user == "" {
return http.StatusBadRequest, fmt.Errorf("user is required")
}
if _, err := state.GetUserByUsername(user); err != nil {
if err == errors.ErrNotExist {
return http.StatusBadRequest, fmt.Errorf("user not found: %s", user)
}
return http.StatusInternalServerError, fmt.Errorf("failed to look up user: %w", err)
}
if err := state.AddUserToGroup(group, user); err != nil {
return http.StatusInternalServerError, err
}
return RenderJSON(w, r, map[string]string{"message": "user added to group"})
@@ -332,14 +367,14 @@ func groupPostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
// groupPutHandler creates a group or replaces its membership.
// @Summary Create or update a group
// @Description Creates the group if missing and replaces its member list.
// @Description Creates the group if missing and replaces its member list. When create is true, a group that already exists returns 409.
// @Tags Access
// @Accept json
// @Produce json
// @Param body body object{group=string,members=[]string} true "Group name and full member list"
// @Success 200 "Group saved successfully"
// @Param body body object{group=string,members=[]string,create=bool} true "Group name and full member list"
// @Success 200 {object} object "Group saved; unknownMembers lists usernames with no matching local user"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 409 {object} map[string]string "Conflict (group already exists when create is true)"
// @Router /api/access/group [put]
func groupPutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if !d.User.Permissions.Admin {
@@ -348,19 +383,43 @@ func groupPutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, e
var body struct {
Group string `json:"group"`
Members []string `json:"members"`
Create bool `json:"create"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
}
defer r.Body.Close()
body.Group = strings.TrimSpace(body.Group)
if body.Group == "" {
name := strings.TrimSpace(body.Group)
if name == "" {
return http.StatusBadRequest, fmt.Errorf("group is required")
}
if err := state.SetGroupMembers(body.Group, body.Members); err != nil {
// Strict naming applies only to newly created groups; existing names always work.
if !state.GroupExists(name) {
if _, err := access.NormalizeGroupName(name); err != nil {
return http.StatusBadRequest, err
}
}
if body.Create && state.GroupExists(name) {
return http.StatusConflict, fmt.Errorf("group already exists: %s", name)
}
members := access.NormalizeMembers(body.Members)
var unknownMembers []string
for _, member := range members {
if _, err := state.GetUserByUsername(member); err != nil {
if err != errors.ErrNotExist {
return http.StatusInternalServerError, fmt.Errorf("failed to look up user: %w", err)
}
unknownMembers = append(unknownMembers, member)
}
}
if err := state.SetGroupMembers(name, members); err != nil {
return http.StatusInternalServerError, err
}
return RenderJSON(w, r, map[string]string{"message": "group saved"})
resp := map[string]interface{}{"message": "group saved"}
if len(unknownMembers) > 0 {
resp["unknownMembers"] = unknownMembers
}
return RenderJSON(w, r, resp)
}
// groupDeleteHandler removes a user from a group, or deletes the group when no user is given.
@@ -379,8 +438,8 @@ func groupDeleteHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
if !d.User.Permissions.Admin {
return http.StatusForbidden, nil
}
group := r.URL.Query().Get("group")
user := r.URL.Query().Get("user")
group := strings.TrimSpace(r.URL.Query().Get("group"))
user := strings.TrimSpace(r.URL.Query().Get("user"))
if group == "" {
return http.StatusBadRequest, fmt.Errorf("group is required")
}
@@ -398,6 +457,56 @@ func groupDeleteHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
return RenderJSON(w, r, map[string]string{"message": "user removed from group"})
}
// userGroupsPutHandler atomically replaces a user's group memberships.
// @Summary Replace a user's group memberships
// @Description Replaces all of a user's group memberships in one request. Every group in the list must already exist.
// @Tags Access
// @Accept json
// @Produce json
// @Param body body object{user=string,groups=[]string} true "Username and full group list"
// @Success 200 {object} map[string]string "User groups updated"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 404 {object} map[string]string "User not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/access/user-groups [put]
func userGroupsPutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if !d.User.Permissions.Admin {
return http.StatusForbidden, nil
}
var body struct {
User string `json:"user"`
Groups []string `json:"groups"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
}
defer r.Body.Close()
user := strings.TrimSpace(body.User)
if user == "" {
return http.StatusBadRequest, fmt.Errorf("user is required")
}
if _, err := state.GetUserByUsername(user); err != nil {
if err == errors.ErrNotExist {
return http.StatusNotFound, fmt.Errorf("user not found: %s", user)
}
return http.StatusInternalServerError, fmt.Errorf("failed to look up user: %w", err)
}
groups := access.NormalizeMembers(body.Groups)
var unknown []string
for _, g := range groups {
if !state.GroupExists(g) {
unknown = append(unknown, g)
}
}
if len(unknown) > 0 {
return http.StatusBadRequest, fmt.Errorf("unknown groups: %s", strings.Join(unknown, ", "))
}
if err := state.SyncUserGroups(user, groups); err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to update user groups: %w", err)
}
return RenderJSON(w, r, map[string]string{"message": "user groups updated"})
}
// accessPatchHandler updates an access rule's path.
// @Summary Update access rule path
// @Description Updates the path for a specific access rule
+177
View File
@@ -5,7 +5,10 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
activityrec "github.com/gtsteffaniak/filebrowser/backend/internal/activity"
@@ -266,8 +269,18 @@ func TestGroupPutHandler_Validation(t *testing.T) {
}
}
func createAccessTestUser(t *testing.T, name string) {
t.Helper()
u := &users.User{FrontendUser: users.FrontendUser{Username: name}}
if err := state.CreateUser(u, ""); err != nil {
t.Fatal(err)
}
}
func TestGroupPutHandler_ReplacesMembersAndReturnsJSON(t *testing.T) {
setupAccessHTTPTest(t)
createAccessTestUser(t, "alice")
createAccessTestUser(t, "bob")
ctx := groupCtx(t, "admin", true)
status, rec, err := doGroupRequest(t, groupPutHandler, http.MethodPut, "/api/access/group", `{"group":" editors ","members":["alice","bob"]}`, ctx)
@@ -322,6 +335,7 @@ func TestGroupDeleteHandler_MemberVersusWholeGroup(t *testing.T) {
func TestGroupPostHandler_ReturnsJSON(t *testing.T) {
setupAccessHTTPTest(t)
createAccessTestUser(t, "alice")
status, rec, err := doGroupRequest(t, groupPostHandler, http.MethodPost, "/api/access/group?group=editors&user=alice", "", groupCtx(t, "admin", true))
if status != http.StatusOK || err != nil {
t.Fatalf("expected 200, got %d (err=%v)", status, err)
@@ -361,3 +375,166 @@ func TestGroupGetHandler_MembersParam(t *testing.T) {
t.Fatalf("expected members[editors]=[alice], got %+v", withMembers)
}
}
func TestGroupPostHandler_Validation(t *testing.T) {
setupAccessHTTPTest(t)
createAccessTestUser(t, "alice")
ctx := groupCtx(t, "admin", true)
for name, target := range map[string]string{
"empty group": "/api/access/group?group=&user=alice",
"blank group": "/api/access/group?group=%20%20&user=alice",
"missing user": "/api/access/group?group=editors",
"unknown user": "/api/access/group?group=editors&user=ghost",
"control char": "/api/access/group?group=a%0Ab&user=alice",
"name too long": "/api/access/group?group=" + strings.Repeat("a", 200) + "&user=alice",
} {
status, _, _ := doGroupRequest(t, groupPostHandler, http.MethodPost, target, "", ctx)
if status != http.StatusBadRequest {
t.Fatalf("%s: expected 400, got %d", name, status)
}
}
if len(state.GetAllGroups()) != 0 {
t.Fatal("invalid requests must not create groups")
}
}
func TestGroupPutHandler_CreateFlagAndUnknownMembers(t *testing.T) {
setupAccessHTTPTest(t)
createAccessTestUser(t, "alice")
ctx := groupCtx(t, "admin", true)
status, rec, err := doGroupRequest(t, groupPutHandler, http.MethodPut, "/api/access/group",
`{"group":"editors","members":["alice","ghost"],"create":true}`, ctx)
if status != http.StatusOK || err != nil {
t.Fatalf("expected 200, got %d (err=%v)", status, err)
}
var resp struct {
Message string `json:"message"`
UnknownMembers []string `json:"unknownMembers"`
}
if err = json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.UnknownMembers) != 1 || resp.UnknownMembers[0] != "ghost" {
t.Fatalf("expected unknownMembers=[ghost], got %v", resp.UnknownMembers)
}
// Unknown members are still stored intentionally (IdP users may not exist locally yet).
if got := state.GetGroupMembers()["editors"]; len(got) != 2 {
t.Fatalf("expected both members stored, got %v", got)
}
// Creating an existing group again is a conflict.
status, _, _ = doGroupRequest(t, groupPutHandler, http.MethodPut, "/api/access/group",
`{"group":"editors","members":[],"create":true}`, ctx)
if status != http.StatusConflict {
t.Fatalf("expected 409, got %d", status)
}
// Without create it remains an upsert.
status, _, err = doGroupRequest(t, groupPutHandler, http.MethodPut, "/api/access/group",
`{"group":"editors","members":["alice"]}`, ctx)
if status != http.StatusOK || err != nil {
t.Fatalf("expected upsert 200, got %d (err=%v)", status, err)
}
}
func TestAccessPostHandler_GroupCategoryValidation(t *testing.T) {
setupAccessHTTPTest(t)
ctx := groupCtx(t, "admin", true)
post := func(value string) int {
req := httptest.NewRequest(http.MethodPost, "/api/access?source=Downloads&path=%2F",
bytes.NewReader([]byte(`{"allow":true,"ruleCategory":"group","value":"`+value+`"}`)))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
status, _ := accessPostHandler(rec, req, ctx)
return status
}
if status := post("ghost"); status != http.StatusBadRequest {
t.Fatalf("expected 400 for unknown group, got %d", status)
}
if state.GroupExists("ghost") {
t.Fatal("rule POST must not create a group")
}
if err := state.SetGroupMembers("editors", nil); err != nil {
t.Fatal(err)
}
if status := post("editors"); status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if status := post("editors"); status != http.StatusConflict {
t.Fatalf("expected 409 for duplicate entry, got %d", status)
}
}
func TestGroupHandlers_LegacyNamesStillWork(t *testing.T) {
setupAccessHTTPTest(t)
ctx := groupCtx(t, "admin", true)
// A name longer than the create limit and with characters strict validation rejects.
legacy := "cn=admins with spaces,ou=legacy," + strings.Repeat("x", 150)
if err := state.SetGroupMembers(legacy, []string{"alice"}); err != nil {
t.Fatal(err)
}
// Listing still returns it.
status, rec, err := doGroupRequest(t, groupGetHandler, http.MethodGet, "/api/access/groups?members=true", "", ctx)
if status != http.StatusOK || err != nil {
t.Fatalf("expected 200, got %d (err=%v)", status, err)
}
var resp GroupListResponse
if err = json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Members[legacy]) != 1 {
t.Fatalf("expected legacy group listed, got %+v", resp.Members)
}
// PUT can still replace its members even though the name fails create-time validation.
status, _, err = doGroupRequest(t, groupPutHandler, http.MethodPut, "/api/access/group",
`{"group":`+strconv.Quote(legacy)+`,"members":["bob"]}`, ctx)
if status != http.StatusOK || err != nil {
t.Fatalf("expected 200 editing legacy group, got %d (err=%v)", status, err)
}
// DELETE still removes it.
status, _, err = doGroupRequest(t, groupDeleteHandler, http.MethodDelete,
"/api/access/group?group="+url.QueryEscape(legacy), "", ctx)
if status != http.StatusOK || err != nil {
t.Fatalf("expected 200 deleting legacy group, got %d (err=%v)", status, err)
}
if state.GroupExists(legacy) {
t.Fatal("legacy group should be deleted")
}
}
func TestUserGroupsPutHandler(t *testing.T) {
setupAccessHTTPTest(t)
createAccessTestUser(t, "alice")
ctx := groupCtx(t, "admin", true)
if err := state.SetGroupMembers("editors", nil); err != nil {
t.Fatal(err)
}
if err := state.SetGroupMembers("staff", nil); err != nil {
t.Fatal(err)
}
do := func(body string) int {
status, _, _ := doGroupRequest(t, userGroupsPutHandler, http.MethodPut, "/api/access/user-groups", body, ctx)
return status
}
if status := do(`{"user":"alice","groups":["editors","staff"]}`); status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if got := state.GetUserGroups("alice"); len(got) != 2 {
t.Fatalf("expected 2 groups, got %v", got)
}
if status := do(`{"user":"alice","groups":["editors"]}`); status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if got := state.GetUserGroups("alice"); len(got) != 1 || got[0] != "editors" {
t.Fatalf("expected [editors], got %v", got)
}
if status := do(`{"user":"alice","groups":["ghost"]}`); status != http.StatusBadRequest {
t.Fatalf("expected 400 for unknown group, got %d", status)
}
if status := do(`{"user":"ghost","groups":[]}`); status != http.StatusNotFound {
t.Fatalf("expected 404 for unknown user, got %d", status)
}
if status := do(`{"groups":["editors"]}`); status != http.StatusBadRequest {
t.Fatalf("expected 400 for missing user, got %d", status)
}
}
+54 -11
View File
@@ -22,15 +22,15 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/archiveencoding"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/time/rate"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
// archiveMultiRequestIdle is how long without another Range/HEAD on the same archiveToken before
@@ -391,11 +391,13 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
// @Description - **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: `"/downloads/data.zip"`
// @Description - **destination** (string, required): Directory path (on toSource) to extract into. Example: `"/projects/imported"`
// @Description - **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: `true`
// @Description - **filenameEncoding** (string, optional): Fallback ZIP filename encoding: utf-8, cp932, gb18030, big5, euc-kr, cp437, windows-1252. Unicode metadata takes precedence. Omit to use a suggestion; ambiguous encodings require a selection.
// @Description - **preview** (boolean, optional): Return ZIP encoding candidates and sample filenames without extracting or deleting anything. Requires the same source/destination permissions as extraction.
// @Tags Resources
// @Accept json
// @Produce json
// @Param body body unarchiveRequest true "Request body: fromSource, toSource (optional), path, destination, deleteAfter (optional)"
// @Success 200 {object} map[string]string "Extracted; returns {\"path\": \"<destination path>\", \"source\": \"<toSource>\"}"
// @Success 200 {object} map[string]interface{} "Preview returns suggested and candidates; extraction returns path and source. Extracted; returns {\"path\": \"<destination path>\", \"source\": \"<toSource>\"}"
// @Failure 400 {object} map[string]string "Invalid request (e.g. missing required field, unsupported format)"
// @Failure 403 {object} map[string]string "Forbidden (create permission or access denied)"
// @Failure 404 {object} map[string]string "Source or archive file not found"
@@ -431,7 +433,7 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
if !fromPerms.Download {
return http.StatusForbidden, fmt.Errorf("user is not allowed to download archive source")
}
if req.DeleteAfter && !fromPerms.Delete {
if req.DeleteAfter && !req.Preview && !fromPerms.Delete {
return http.StatusForbidden, fmt.Errorf("user is not allowed to delete archive source")
}
@@ -508,15 +510,29 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
}
lower := strings.ToLower(archiveReal)
if req.Preview {
if !strings.HasSuffix(lower, ".zip") {
return http.StatusBadRequest, fmt.Errorf("encoding preview requires a ZIP archive")
}
zr, openErr := zip.OpenReader(archiveReal)
if openErr != nil {
return http.StatusBadRequest, openErr
}
defer zr.Close()
return RenderJSON(w, r, archiveencoding.Inspect(zr.File), http.StatusOK)
}
var extractErr error
if strings.HasSuffix(lower, ".zip") {
extractErr = extractZip(archiveReal, destReal)
extractErr = extractZipWithEncoding(archiveReal, destReal, req.FilenameEncoding)
} else if strings.HasSuffix(lower, ".tar.gz") || (strings.HasSuffix(lower, ".tgz")) {
extractErr = extractTarGz(archiveReal, destReal)
} else {
return http.StatusBadRequest, fmt.Errorf("unsupported archive format (use .zip or .tar.gz)")
}
if extractErr != nil {
if errors.Is(extractErr, archiveencoding.ErrEncoding) {
return http.StatusBadRequest, extractErr
}
return http.StatusInternalServerError, extractErr
}
@@ -937,6 +953,10 @@ type unarchiveRequest struct {
Destination string `json:"destination"`
// If true, delete the archive file after successful extraction (optional; default: false). Example: true
DeleteAfter bool `json:"deleteAfter"`
// Fallback ZIP filename encoding; explicit Unicode metadata takes precedence.
FilenameEncoding string `json:"filenameEncoding,omitempty"`
// Inspect ZIP names without writing or deleting files.
Preview bool `json:"preview,omitempty"`
}
// normalizeArchiveEntryName turns a raw zip/tar name into a safe relative path (slash-separated).
@@ -1119,11 +1139,32 @@ func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time
}
func extractZip(archivePath, destDir string) error {
return extractZipWithEncoding(archivePath, destDir, "")
}
func extractZipWithEncoding(archivePath, destDir, charset string) error {
r, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer r.Close()
names, err := archiveencoding.DecodeNames(r.File, charset)
if err != nil {
return err
}
// Decode before interpreting separators: CP932 characters can contain byte 0x5c.
// Validate every name before creating any files, including normalized collisions.
destinations := make(map[string]string, len(names))
for i, name := range names {
dest, pathErr := safeExtractPath(destDir, name)
if pathErr != nil {
return pathErr
}
if original, exists := destinations[dest]; exists && original != r.File[i].Name {
return fmt.Errorf("ZIP filenames resolve to the same destination: %q", name)
}
destinations[dest] = r.File[i].Name
}
// Central directory order is arbitrary. Some zips (e.g. Windows driver packs) use names that
// are similar length at different tree depths, so byte length is a poor order key. Use the
@@ -1131,20 +1172,22 @@ func extractZip(archivePath, destDir string) error {
// lexicographic, so parent dirs exist before shorter entries that might be mis-tagged.
type orderedName struct {
f *zip.File
name string
rel string
depth int
}
ordered := make([]orderedName, 0, len(r.File))
for _, f := range r.File {
rel, nerr := normalizeArchiveEntryName(f.Name)
for i, f := range r.File {
name := names[i]
rel, nerr := normalizeArchiveEntryName(name)
if nerr != nil {
rel = strings.TrimLeft(strings.ReplaceAll(strings.TrimSpace(f.Name), "\\", "/"), "/")
rel = strings.TrimLeft(strings.ReplaceAll(strings.TrimSpace(name), "\\", "/"), "/")
}
depth := 0
if rel != "" {
depth = strings.Count(rel, "/") + 1
}
ordered = append(ordered, orderedName{f: f, rel: rel, depth: depth})
ordered = append(ordered, orderedName{f: f, name: name, rel: rel, depth: depth})
}
sort.Slice(ordered, func(i, j int) bool {
if ordered[i].depth != ordered[j].depth {
@@ -1159,14 +1202,14 @@ func extractZip(archivePath, destDir string) error {
for _, o := range ordered {
f := o.f
var destPath string
destPath, err = safeExtractPath(destDir, f.Name)
destPath, err = safeExtractPath(destDir, o.name)
if err != nil {
return err
}
fi := f.FileInfo()
mode := fi.Mode()
isDirEntry := mode.Type() == fs.ModeDir || strings.HasSuffix(f.Name, "/") || strings.HasSuffix(f.Name, "\\")
isDirEntry := mode.Type() == fs.ModeDir || strings.HasSuffix(o.name, "/") || strings.HasSuffix(o.name, "\\")
if isDirEntry {
if err = extractArchivedDir(destPath, mode, f.Modified); err != nil {
return err
@@ -0,0 +1,68 @@
package web
import (
"archive/zip"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/archiveencoding"
)
func TestUnarchiveEncodingPreviewDoesNotWriteOrDelete(t *testing.T) {
root, user := setupUploadHTTPTest(t)
archivePath := filepath.Join(root, "test.zip")
f, err := os.Create(archivePath)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
w, err := zw.CreateHeader(&zip.FileHeader{Name: "\x83\x65\x83\x58\x83\x67.txt", NonUTF8: true})
if err != nil {
t.Fatal(err)
}
if _, err = w.Write([]byte("hello")); err != nil {
t.Fatal(err)
}
if err = zw.Close(); err != nil {
t.Fatal(err)
}
if err = f.Close(); err != nil {
t.Fatal(err)
}
body := `{"fromSource":"uploads","path":"/test.zip","destination":"/","preview":true,"deleteAfter":true}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/resources/unarchive", strings.NewReader(body))
status, err := unarchiveHandler(rec, req, &Context{User: user})
if err != nil || status != http.StatusOK {
t.Fatalf("status=%d err=%v body=%s", status, err, rec.Body.String())
}
var preview archiveencoding.Preview
if err = json.Unmarshal(rec.Body.Bytes(), &preview); err != nil {
t.Fatal(err)
}
if preview.Suggested != "cp932" {
t.Fatalf("preview: %+v", preview)
}
entries, err := os.ReadDir(root)
if err != nil || len(entries) != 1 || entries[0].Name() != "test.zip" {
t.Fatalf("preview modified source: %v, %v", entries, err)
}
// The preview goes through the same source permission checks as extraction.
perms := user.BackendSourcePermissions[root]
perms.Download = false
user.BackendSourcePermissions[root] = perms
user.BackendScopes[0].Permissions.Download = false
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/resources/unarchive", strings.NewReader(body))
status, err = unarchiveHandler(rec, req, &Context{User: user})
if status != http.StatusForbidden || err == nil {
t.Fatalf("expected denied preview, got %d, %v", status, err)
}
}
+56
View File
@@ -287,3 +287,59 @@ func BenchmarkNormalizeArchiveEntryName(b *testing.B) {
_, _ = normalizeArchiveEntryName(name)
}
}
func TestExtractZipFilenameEncoding(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, raw, charset, want string
invalid bool
}{
{name: "cp932 trail byte", raw: "\x83\x65\x83\x58\x83\x67/\x83\x5c.txt", charset: "cp932", want: "テスト/ソ.txt"},
{name: "automatic cp932", raw: "\x83\x65\x83\x58\x83\x67/\x83\x5c.txt", want: "テスト/ソ.txt"},
{name: "traversal after decoding", raw: "../\x83\x5c.txt", charset: "cp932", invalid: true},
{name: "invalid encoding", raw: "file.txt", charset: "unknown", invalid: true},
{name: "ambiguous", raw: "caf\x82.txt", invalid: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
archivePath := filepath.Join(t.TempDir(), "test.zip")
f, err := os.Create(archivePath)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
w, err := zw.CreateHeader(&zip.FileHeader{Name: tc.raw, NonUTF8: true})
if err != nil {
t.Fatal(err)
}
if _, err = w.Write([]byte("test content")); err != nil {
t.Fatal(err)
}
if err = zw.Close(); err != nil {
t.Fatal(err)
}
if err = f.Close(); err != nil {
t.Fatal(err)
}
out := t.TempDir()
err = extractZipWithEncoding(archivePath, out, tc.charset)
if tc.invalid {
if err == nil {
t.Fatal("expected error")
}
entries, readErr := os.ReadDir(out)
if readErr != nil || len(entries) != 0 {
t.Fatal("failed validation wrote files")
}
return
}
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(filepath.Join(out, tc.want))
if err != nil || string(content) != "test content" {
t.Fatalf("content=%q err=%v", content, err)
}
})
}
}
+96 -23
View File
@@ -12,6 +12,7 @@ import (
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"golang.org/x/crypto/bcrypt"
@@ -83,28 +84,8 @@ func ExtractToken(r *http.Request) (string, error) {
// When groupsPresent is false the groups claim was omitted and existing memberships are left unchanged.
// When groupsPresent is true, groups (including empty) are synced into access-control GroupMap.
func getOrCreateAuthenticatedUser(username string, loginMethod users.LoginMethod, isAdmin bool, groups []string, groupsPresent bool) (*users.User, error) {
allowedGroups := []string{}
switch loginMethod {
case users.LoginMethodJwt:
allowedGroups = settings.Config.Auth.Methods.JwtAuth.UserGroups
case users.LoginMethodLdap:
allowedGroups = settings.Config.Auth.Methods.LdapAuth.UserGroups
case users.LoginMethodOidc:
allowedGroups = settings.Config.Auth.Methods.OidcAuth.UserGroups
case users.LoginMethodProxy:
allowedGroups = settings.Config.Auth.Methods.ProxyAuth.UserGroups
}
allowed := len(allowedGroups) == 0
for _, userGroup := range groups {
for _, allowedGroup := range allowedGroups {
if userGroup == allowedGroup {
allowed = true
break
}
}
}
if !allowed {
return nil, fmt.Errorf("user is not in allowed groups")
if err := ensureUserInAllowedGroups(loginMethod, groups); err != nil {
return nil, err
}
// Try to get existing user
@@ -170,6 +151,38 @@ func isAdminFromGroups(adminGroup string, groups []string) bool {
return adminGroup != "" && slices.Contains(groups, adminGroup)
}
// ensureUserInAllowedGroups rejects login when userGroups is configured and none
// of the IdP groups are allowed. Must run before auto-create so denied users
// leave no account behind.
func ensureUserInAllowedGroups(loginMethod users.LoginMethod, groups []string) error {
allowedGroups := []string{}
switch loginMethod {
case users.LoginMethodJwt:
allowedGroups = settings.Config.Auth.Methods.JwtAuth.UserGroups
case users.LoginMethodLdap:
allowedGroups = settings.Config.Auth.Methods.LdapAuth.UserGroups
case users.LoginMethodOidc:
allowedGroups = settings.Config.Auth.Methods.OidcAuth.UserGroups
case users.LoginMethodProxy:
allowedGroups = settings.Config.Auth.Methods.ProxyAuth.UserGroups
}
if len(allowedGroups) == 0 {
return nil
}
for _, userGroup := range groups {
for _, allowedGroup := range allowedGroups {
if loginMethod == users.LoginMethodLdap {
if ldapGroupMatches(userGroup, allowedGroup) {
return nil
}
} else if strings.EqualFold(userGroup, allowedGroup) {
return nil
}
}
}
return fmt.Errorf("user is not in allowed groups")
}
func SetupProxyUser(r *http.Request, data *Context, proxyUser string) (*users.User, error) {
proxyCfg := settings.Config.Auth.Methods.ProxyAuth
groups, groupsPresent := auth.ExtractGroupsFromHeader(r, proxyCfg.GroupsClaim)
@@ -215,6 +228,9 @@ func loginHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, erro
if d.User.HasPasskeyMFA() && d.User.TOTPSecret == "" {
return http.StatusForbidden, errors.ErrPasskeyMFARequired
}
if passwordUser && d.User.RequirePasswordChange {
return http.StatusForbidden, errors.ErrPasswordChangeRequired
}
status, err := printToken(w, r, d.User, "")
if err != nil || status != 0 {
return status, err
@@ -315,6 +331,9 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
if username == "" || password == "" {
return http.StatusBadRequest, fmt.Errorf("username and password are required")
}
if err := settings.ValidatePasswordPolicy(password); err != nil {
return http.StatusBadRequest, err
}
user := users.User{
FrontendUser: users.FrontendUser{
@@ -363,12 +382,66 @@ func parseSignupCredentials(r *http.Request) (username, password string, err err
// @Produce json
// @Success 200 {string} string "New JWT token generated"
// @Failure 401 {object} map[string]string "Unauthorized - invalid token"
// @Failure 403 {object} map[string]string "Forbidden - non-session token"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/renew [post]
func renewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if settings.Config.Auth.Methods.NoAuth {
w.Header().Set("Content-Type", "text/plain")
if d.Token != "" {
if _, err := w.Write([]byte(d.Token)); err != nil {
return 401, errors.ErrUnauthorized
}
}
return 0, nil
}
token := d.Token
if token == "" {
extracted, err := ExtractToken(r)
if err != nil || extracted == "" {
return http.StatusUnauthorized, errors.ErrUnauthorized
}
token = extracted
d.Token = token
}
_, isSession, ok := state.HashedTokenOwner(token)
if ok && !isSession {
return http.StatusForbidden, fmt.Errorf("renew requires a session token")
}
if !ok {
return http.StatusUnauthorized, errors.ErrUnauthorized
}
// Renew is idempotent while the presented session token is still fresh:
// without this every page load rotates the token (the frontend tracks exp
// in memory only), churning token state. Rotation happens only inside the
// refresh window, matching the frontend's renew threshold.
var tk users.AuthToken
if _, err := jwt.ParseWithClaims(d.Token, &tk, auth.JWTSigningKeyFunc()); err == nil &&
tk.RegisteredClaims.ExpiresAt != nil &&
time.Until(tk.RegisteredClaims.ExpiresAt.Time) > sessionRenewWindow {
// Do not re-emit the cookie when the request already carried this token.
// A concurrent renew may have rotated the jar; an in-flight idempotent
// response would otherwise revert the browser to a retiring token.
if c, err := r.Cookie(sessionCookieName); err != nil || c.Value == "" || c.Value != d.Token {
SetSessionCookie(w, r, d.Token, tk.RegisteredClaims.ExpiresAt.Add(sessionCookieBuffer))
}
w.Header().Set("Content-Type", "text/plain")
if _, err := w.Write([]byte(d.Token)); err != nil {
return 401, errors.ErrUnauthorized
}
return 0, nil
}
return printToken(w, r, d.User, d.Token)
}
// sessionRenewWindow mirrors the frontend SESSION_REFRESH_BEFORE_MS: renews
// arriving while the session token has more than this left return it unchanged.
const sessionRenewWindow = 30 * time.Minute
// sessionCookieBuffer keeps the browser cookie briefly past JWT expiry so an
// in-flight renew can still authenticate against the just-expired token.
const sessionCookieBuffer = 30 * time.Minute
func printToken(w http.ResponseWriter, r *http.Request, user *users.User, priorToken string) (int, error) {
expires := time.Hour * time.Duration(settings.Config.Auth.TokenExpirationHours)
tokenString, err := replaceSessionToken(priorToken, user)
@@ -379,7 +452,7 @@ func printToken(w http.ResponseWriter, r *http.Request, user *users.User, priorT
return 401, errors.ErrUnauthorized
}
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
expiresTime := time.Now().Add(expires).Add(sessionCookieBuffer)
SetSessionCookie(w, r, tokenString, expiresTime)
+74 -1
View File
@@ -61,6 +61,79 @@ func TestGetOrCreateAuthenticatedUserRejectsPasswordUserForOIDCAccount(t *testin
}
}
func TestGetOrCreateAuthenticatedUserLDAPUserGroupsCNMatch(t *testing.T) {
setupTestEnv(t)
orig := settings.Config.Auth.Methods.LdapAuth.UserGroups
settings.Config.Auth.Methods.LdapAuth.UserGroups = []string{"employees"}
t.Cleanup(func() {
settings.Config.Auth.Methods.LdapAuth.UserGroups = orig
})
ldapUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "ldap-user",
LoginMethod: users.LoginMethodLdap,
},
}
if err := state.CreateUser(ldapUser, ""); err != nil {
t.Fatalf("CreateUser: %v", err)
}
groups := []string{"cn=Employees,ou=groups,dc=example,dc=com"}
_, err := getOrCreateAuthenticatedUser("ldap-user", users.LoginMethodLdap, false, groups, true)
if err != nil {
t.Fatalf("getOrCreateAuthenticatedUser() with CN-only userGroups: %v", err)
}
}
func TestGetOrCreateAuthenticatedUserLDAPUserGroupsDenied(t *testing.T) {
setupTestEnv(t)
orig := settings.Config.Auth.Methods.LdapAuth.UserGroups
settings.Config.Auth.Methods.LdapAuth.UserGroups = []string{"employees"}
t.Cleanup(func() {
settings.Config.Auth.Methods.LdapAuth.UserGroups = orig
})
ldapUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "ldap-user",
LoginMethod: users.LoginMethodLdap,
},
}
if err := state.CreateUser(ldapUser, ""); err != nil {
t.Fatalf("CreateUser: %v", err)
}
groups := []string{"cn=Contractors,ou=groups,dc=example,dc=com"}
_, err := getOrCreateAuthenticatedUser("ldap-user", users.LoginMethodLdap, false, groups, true)
if err == nil || err.Error() != "user is not in allowed groups" {
t.Fatalf("getOrCreateAuthenticatedUser() err = %v, want user is not in allowed groups", err)
}
}
func TestGetOrCreateAuthenticatedUserLDAPDeniedFirstLoginCreatesNoAccount(t *testing.T) {
setupTestEnv(t)
orig := settings.Config.Auth.Methods.LdapAuth.UserGroups
settings.Config.Auth.Methods.LdapAuth.UserGroups = []string{"employees"}
t.Cleanup(func() {
settings.Config.Auth.Methods.LdapAuth.UserGroups = orig
})
groups := []string{"cn=Contractors,ou=groups,dc=example,dc=com"}
_, err := getOrCreateAuthenticatedUser("denied-ldap-user", users.LoginMethodLdap, false, groups, true)
if err == nil || err.Error() != "user is not in allowed groups" {
t.Fatalf("getOrCreateAuthenticatedUser() err = %v, want user is not in allowed groups", err)
}
_, getErr := state.GetUserByUsername("denied-ldap-user")
if !errors.Is(getErr, fberrors.ErrNotExist) {
t.Fatalf("GetUserByUsername after denied login: %v, want ErrNotExist", getErr)
}
}
func TestAuthenticatePasswordRejectsWrongLoginMethod(t *testing.T) {
setupTestEnv(t)
@@ -77,7 +150,7 @@ func TestAuthenticatePasswordRejectsWrongLoginMethod(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/auth/login?username=graham", nil)
req.Header.Set("X-Password", url.QueryEscape("secret"))
_, err := auth.AuthenticatePassword(req, true)
_, err := auth.AuthenticatePassword(req, true, false)
if !errors.Is(err, fberrors.ErrWrongLoginMethod) {
t.Fatalf("AuthenticatePassword() err = %v, want ErrWrongLoginMethod", err)
}
+91 -22
View File
@@ -151,25 +151,8 @@ func downloadHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, e
// @Failure 501 {object} map[string]string "Downloads disabled for upload shares"
// @Router /public/api/resources/download [get]
func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("downloads are disabled for upload shares")
}
if d.Share.DisableDownload {
return http.StatusForbidden, fmt.Errorf("downloads are not allowed for this share")
}
if !d.Share.PerUserDownloadLimit && d.Share.DownloadsLimit > 0 && d.Share.Downloads >= d.Share.DownloadsLimit {
return http.StatusForbidden, fmt.Errorf("share downloads limit reached")
}
if d.Share.PerUserDownloadLimit {
if d.User.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("anonymous downloads are not allowed with per-user limits")
}
if d.Share.HasReachedUserLimit(d.User.Username) {
return http.StatusForbidden, fmt.Errorf("user download limit reached for this share")
}
if status, err := publicShareDownloadGate(d); err != nil {
return status, err
}
files := r.URL.Query()["file"]
@@ -213,6 +196,57 @@ func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *Context) (
return status, nil
}
// publicShareDownloadGate enforces upload-share, disable-download, and download-counter rules for public share byte egress.
func publicShareDownloadGate(d *Context) (int, error) {
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("downloads are disabled for upload shares")
}
if d.Share.DisableDownload {
return http.StatusForbidden, fmt.Errorf("downloads are not allowed for this share")
}
if status, err := publicShareDownloadLimits(d); err != nil {
return status, err
}
return 0, nil
}
func publicShareDownloadLimits(d *Context) (int, error) {
if !d.Share.PerUserDownloadLimit && d.Share.DownloadsLimit > 0 && d.Share.Downloads >= d.Share.DownloadsLimit {
return http.StatusForbidden, fmt.Errorf("share downloads limit reached")
}
if d.Share.PerUserDownloadLimit {
if d.User.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("anonymous downloads are not allowed with per-user limits")
}
if d.Share.HasReachedUserLimit(d.User.Username) {
return http.StatusForbidden, fmt.Errorf("user download limit reached for this share")
}
}
return 0, nil
}
// publicShareMediaContentPolicy applies the same share view/download flags and download limits as other public content routes.
func publicShareMediaContentPolicy(d *Context) (int, error) {
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
}
sourceInfo, ok := settings.Config.Server.SourceMap[d.Share.SourcePath]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
filePerms, err := effectiveFilePerms(d, sourceInfo.Name)
if err != nil {
return http.StatusForbidden, err
}
if !filePerms.View {
return http.StatusForbidden, fmt.Errorf("user is not allowed to view files in this source")
}
if !filePerms.Download {
return http.StatusForbidden, fmt.Errorf("downloads are not allowed for this share")
}
return publicShareDownloadLimits(d)
}
func RawFilesHandler(w http.ResponseWriter, r *http.Request, d *Context, source string, fileList []string) (int, error) {
if d.Share.Hash == "" {
filePerms, err := effectiveFilePerms(d, source)
@@ -229,7 +263,6 @@ func RawFilesHandler(w http.ResponseWriter, r *http.Request, d *Context, source
}
firstFilePath := fileList[0]
displayFileList := ResolveDisplayFileList(d, source, fileList)
var err error
var status int
var userscope string
@@ -250,6 +283,38 @@ func RawFilesHandler(w http.ResponseWriter, r *http.Request, d *Context, source
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
// Remember how many paths the caller originally requested. After the access filter
// below shrinks the list, this count decides whether to serve raw vs. archive:
// a multi-path request must always yield an archive even if only one path survives.
originalCount := len(fileList)
// For non-share downloads, silently filter out paths the user is denied access to.
// Users should only download what they can see in the UI; a denied path must not
// block the rest of the selection. If nothing remains after filtering, return 404
// (same as a missing file) so that denied and nonexistent paths look identical.
if d.Share.Hash == "" {
permUser := accessCheckUsername(d)
allowed := fileList[:0]
for _, filePath := range fileList {
if state.AccessPermitted(idx.Path, utils.IndexPathFromNormalized(filePath, true), permUser) {
allowed = append(allowed, filePath)
} else {
logger.Debugf("download: skipping denied path for user %q", permUser)
}
}
if len(allowed) == 0 {
return realPathErrStatus(errors.ErrNotExist), errors.ErrNotExist
}
fileList = allowed
firstFilePath = fileList[0]
fileName = filepath.Base(firstFilePath)
}
// Build the display list from the filtered fileList so denied paths are never
// recorded in the download activity log.
displayFileList := ResolveDisplayFileList(d, source, fileList)
var isDir bool
if d.Share.Hash != "" {
if d.Share.Path == "" {
@@ -260,10 +325,14 @@ func RawFilesHandler(w http.ResponseWriter, r *http.Request, d *Context, source
_, isDir, err = idx.GetRealPath(firstFilePath)
}
if err != nil {
return realPathErrStatus(err), err
s := realPathErrStatus(err)
if s == http.StatusNotFound {
return s, errors.ErrNotExist
}
return s, err
}
if len(fileList) == 1 && !isDir {
if len(fileList) == 1 && !isDir && originalCount == 1 {
forceInline := false
forceInline, err = resolveDownloadInlineDisposition(fileName, r.URL.Query().Get("inline") == "true")
if err != nil {
+536
View File
@@ -10,8 +10,11 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
@@ -217,6 +220,191 @@ func TestRawFilesHandlerShareDirectoryDownloadNotEmptyZip(t *testing.T) {
}
}
// TestRawFilesHandler_MixedPathsZipContainsAllowedOnly verifies that a multi-path
// ZIP download where some paths are access-denied returns 200 and includes only the
// files from allowed paths (issue #3098 follow-up: silently skip denied paths).
func TestRawFilesHandler_MixedPathsZipContainsAllowedOnly(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
// Create a second directory that owner is denied (DenyByDefault, no allow rule).
deniedDir := filepath.Join(sourceRoot, "denied_dir")
if err := os.MkdirAll(deniedDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deniedDir, "secret.txt"), []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
ownerUser := users.User{
FrontendUser: users.FrontendUser{Username: "owner"},
BackendScopes: []users.BackendScope{{
Path: sourceRoot,
Scope: "/",
Permissions: users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
},
}},
}
users.SyncBackendSourcePermissionsMap(&ownerUser)
d := &Context{User: &ownerUser}
req := httptest.NewRequest(http.MethodGet, "/api/resources/download?source="+sourceName+"&file=/shared&file=/denied_dir&algo=zip", nil)
rec := httptest.NewRecorder()
status, err := RawFilesHandler(rec, req, d, sourceName, []string{"/shared", "/denied_dir"})
if err != nil {
t.Fatalf("mixed paths: expected no error, got status=%d err=%v", status, err)
}
if status != http.StatusOK && status != 0 {
t.Fatalf("mixed paths: expected 200, got status=%d", status)
}
body, readErr := io.ReadAll(rec.Body)
if readErr != nil {
t.Fatal(readErr)
}
names := readZipFileNames(body)
// hello.txt must be present (from the allowed /shared path).
found := false
for _, n := range names {
if strings.HasSuffix(n, "hello.txt") {
found = true
break
}
}
if !found {
t.Fatalf("zip %v should contain hello.txt from allowed /shared, but it is absent", names)
}
// secret.txt must be absent (from /denied_dir which was silently filtered).
for _, n := range names {
if strings.Contains(n, "secret.txt") {
t.Fatalf("zip %v must not contain secret.txt from denied /denied_dir, but it does", names)
}
}
}
// TestRawFilesHandler_AllDeniedTarGzReturns404 verifies that a tar.gz download
// where every requested path is access-denied returns 404 (not 200/403).
func TestRawFilesHandler_AllDeniedTarGzReturns404(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
deniedDir := filepath.Join(sourceRoot, "denied_dir")
if err := os.MkdirAll(deniedDir, 0o755); err != nil {
t.Fatal(err)
}
ownerUser := users.User{
FrontendUser: users.FrontendUser{Username: "owner"},
BackendScopes: []users.BackendScope{{
Path: sourceRoot,
Scope: "/",
Permissions: users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
},
}},
}
users.SyncBackendSourcePermissionsMap(&ownerUser)
d := &Context{User: &ownerUser}
req := httptest.NewRequest(http.MethodGet, "/api/resources/download?source="+sourceName+"&file=/denied_dir&algo=tar.gz", nil)
rec := httptest.NewRecorder()
status, err := RawFilesHandler(rec, req, d, sourceName, []string{"/denied_dir"})
if err == nil {
t.Fatal("all-denied tar.gz: expected error, got nil")
}
if status != http.StatusNotFound {
t.Fatalf("all-denied tar.gz: expected 404, got status=%d err=%v", status, err)
}
}
// TestRawFilesHandler_AllDeniedEqualsNotFound verifies that an all-denied download
// returns exactly the same status and error message as a request for a nonexistent
// path, so that denied and missing paths are indistinguishable to the caller.
func TestRawFilesHandler_AllDeniedEqualsNotFound(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
// A directory owner is denied (no allow rule, DenyByDefault=true).
deniedDir := filepath.Join(sourceRoot, "denied_dir")
if err := os.MkdirAll(deniedDir, 0o755); err != nil {
t.Fatal(err)
}
ownerUser := users.User{
FrontendUser: users.FrontendUser{Username: "owner"},
BackendScopes: []users.BackendScope{{
Path: sourceRoot,
Scope: "/",
Permissions: users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
},
}},
}
users.SyncBackendSourcePermissionsMap(&ownerUser)
// Request a path that exists on disk but is access-denied.
dDenied := &Context{User: &ownerUser}
reqDenied := httptest.NewRequest(http.MethodGet, "/api/resources/download?source="+sourceName+"&file=/denied_dir&algo=zip", nil)
recDenied := httptest.NewRecorder()
statusDenied, errDenied := RawFilesHandler(recDenied, reqDenied, dDenied, sourceName, []string{"/denied_dir"})
// Request a path that owner can access but that does not exist on disk.
dMissing := &Context{User: &ownerUser}
reqMissing := httptest.NewRequest(http.MethodGet, "/api/resources/download?source="+sourceName+"&file=/shared/nonexistent.txt", nil)
recMissing := httptest.NewRecorder()
statusMissing, errMissing := RawFilesHandler(recMissing, reqMissing, dMissing, sourceName, []string{"/shared/nonexistent.txt"})
if errDenied == nil || errMissing == nil {
t.Fatalf("expected errors from both: denied=%v, missing=%v", errDenied, errMissing)
}
if statusDenied != statusMissing {
t.Fatalf("status mismatch: denied=%d missing=%d", statusDenied, statusMissing)
}
if errDenied.Error() != errMissing.Error() {
t.Fatalf("error message mismatch: denied=%q missing=%q", errDenied.Error(), errMissing.Error())
}
}
// TestRawFilesHandler_AllowedUserGets200 verifies that a fully permitted user
// still receives 200 with a valid archive (regression guard for issue #3098).
func TestRawFilesHandler_AllowedUserGets200(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
settings.Config.Server.NameToSource[sourceName].Path = sourceRoot
allowedUser := users.User{
FrontendUser: users.FrontendUser{Username: "owner"},
BackendScopes: []users.BackendScope{{
Path: sourceRoot,
Scope: "/",
Permissions: users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
},
}},
}
users.SyncBackendSourcePermissionsMap(&allowedUser)
d := &Context{User: &allowedUser}
req := httptest.NewRequest(http.MethodGet, "/api/resources/download?source="+sourceName+"&file=/shared&algo=zip", nil)
rec := httptest.NewRecorder()
status, err := RawFilesHandler(rec, req, d, sourceName, []string{"/shared"})
if err != nil {
t.Fatalf("allowed user: unexpected error: status=%d err=%v", status, err)
}
if status != http.StatusOK && status != 0 {
t.Fatalf("allowed user: expected 200, got status=%d", status)
}
body, readErr := io.ReadAll(rec.Body)
if readErr != nil {
t.Fatal(readErr)
}
if len(readZipFileNames(body)) == 0 {
t.Fatal("allowed user: expected non-empty ZIP archive")
}
}
func TestRawFilesHandler_MissingFileReturns404(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
ownerUser := users.User{
@@ -309,3 +497,351 @@ func TestServeSingleFile_MissingScopedPathReturns404(t *testing.T) {
t.Fatalf("expected 404, got status=%d err=%v", status, err)
}
}
// TestServeSingleFile_RequiresRevalidation verifies that a single-file download is not
// reused from the browser cache without revalidation, so a file changed on disk is not
// served stale, and that an unchanged file still answers 304.
func TestServeSingleFile_RequiresRevalidation(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
ownerUser := users.User{
FrontendUser: users.FrontendUser{Username: "owner"},
BackendScopes: []users.BackendScope{{
Path: sourceRoot,
Scope: "/",
Permissions: users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
},
}},
}
users.SyncBackendSourcePermissionsMap(&ownerUser)
d := &Context{User: &ownerUser}
rec := httptest.NewRecorder()
status, err := ServeSingleFile(rec, httptest.NewRequest(http.MethodGet, "/", nil), d, sourceName, "/shared/hello.txt", "hello.txt", ServeSingleFileOptions{ForceInline: true})
if err != nil {
t.Fatalf("unexpected error: status=%d err=%v", status, err)
}
if got := rec.Header().Get("Cache-Control"); got != "private, no-cache" {
t.Fatalf("expected Cache-Control %q, got %q", "private, no-cache", got)
}
lastModified := rec.Header().Get("Last-Modified")
if lastModified == "" {
t.Fatal("expected a Last-Modified header to revalidate against")
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("If-Modified-Since", lastModified)
rec = httptest.NewRecorder()
if _, err := ServeSingleFile(rec, req, d, sourceName, "/shared/hello.txt", "hello.txt", ServeSingleFileOptions{ForceInline: true}); err != nil {
t.Fatalf("conditional request: unexpected error: %v", err)
}
if rec.Code != http.StatusNotModified {
t.Fatalf("expected 304 for an unchanged file, got %d", rec.Code)
}
}
// TestServeSingleFile_SameSecondEditIsNotServedStale verifies that an edit within the same
// second as the cached copy, which If-Modified-Since cannot see, changes the ETag so a
// conditional request gets the new content rather than 304.
func TestServeSingleFile_SameSecondEditIsNotServedStale(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
ownerUser := users.User{
FrontendUser: users.FrontendUser{Username: "owner"},
BackendScopes: []users.BackendScope{{
Path: sourceRoot,
Scope: "/",
Permissions: users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
},
}},
}
users.SyncBackendSourcePermissionsMap(&ownerUser)
d := &Context{User: &ownerUser}
filePath := filepath.Join(sourceRoot, "shared", "hello.txt")
modTime := time.Date(2026, 1, 2, 3, 4, 5, 100_000_000, time.UTC)
if err := os.Chtimes(filePath, modTime, modTime); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
if _, err := ServeSingleFile(rec, httptest.NewRequest(http.MethodGet, "/", nil), d, sourceName, "/shared/hello.txt", "hello.txt", ServeSingleFileOptions{ForceInline: true}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
etag, lastModified := rec.Header().Get("ETag"), rec.Header().Get("Last-Modified")
// Same second, same size: only the ETag can tell the two versions apart.
if err := os.WriteFile(filePath, []byte("share-zip-CHANGED!"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(filePath, modTime.Add(time.Millisecond), modTime.Add(time.Millisecond)); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
req.Header.Set("If-Modified-Since", lastModified)
rec = httptest.NewRecorder()
if _, err := ServeSingleFile(rec, req, d, sourceName, "/shared/hello.txt", "hello.txt", ServeSingleFileOptions{ForceInline: true}); err != nil {
t.Fatalf("conditional request: unexpected error: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for a file edited within the same second, got %d", rec.Code)
}
if got := rec.Body.String(); got != "share-zip-CHANGED!" {
t.Fatalf("expected the new content, got %q", got)
}
}
// TestRawFilesHandler_ActivityLogExcludesDeniedPaths verifies that when a download
// mixes allowed and denied paths, only the allowed paths appear in the recorded
// activity entry (the denied path name must not be revealed via the audit log).
func TestRawFilesHandler_ActivityLogExcludesDeniedPaths(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
// Create a directory that owner is denied (DenyByDefault, no allow rule).
deniedDir := filepath.Join(sourceRoot, "denied_dir")
if err := os.MkdirAll(deniedDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deniedDir, "secret.txt"), []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
ownerUser := users.User{
ID: 1,
FrontendUser: users.FrontendUser{Username: "owner"},
BackendScopes: []users.BackendScope{{
Path: sourceRoot,
Scope: "/",
Permissions: users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
},
}},
}
users.SyncBackendSourcePermissionsMap(&ownerUser)
d := &Context{User: &ownerUser}
req := httptest.NewRequest(http.MethodGet, "/api/resources/download?source="+sourceName+"&file=/shared&file=/denied_dir&algo=zip", nil)
rec := httptest.NewRecorder()
status, err := RawFilesHandler(rec, req, d, sourceName, []string{"/shared", "/denied_dir"})
if err != nil {
t.Fatalf("mixed paths: expected no error, got status=%d err=%v", status, err)
}
if status != http.StatusOK && status != 0 {
t.Fatalf("mixed paths: expected 200, got status=%d", status)
}
// Flush the activity buffer so entries are persisted to the SQLite store.
activity.FlushNow()
entries, _, qErr := state.ListActivity(activitydb.QueryFilter{
EventTypes: []activitydb.EventType{activitydb.EventDownload},
Page: 1,
Limit: 20,
})
if qErr != nil {
t.Fatalf("ListActivity: %v", qErr)
}
if len(entries) == 0 {
t.Fatal("expected at least one download activity entry")
}
// Collect all recorded paths across all detail entries for this test's download.
var recordedPaths []string
for _, e := range entries {
recordedPaths = append(recordedPaths, e.Details.Paths...)
if e.Details.Path != "" {
recordedPaths = append(recordedPaths, e.Details.Path)
}
}
// The allowed path (/shared) must appear in the activity log.
allowedFound := false
for _, p := range recordedPaths {
if strings.Contains(p, "shared") {
allowedFound = true
break
}
}
if !allowedFound {
t.Errorf("activity log %v should contain the allowed path 'shared'", recordedPaths)
}
// The denied path name must NOT appear in the activity log.
for _, p := range recordedPaths {
if strings.Contains(p, "denied_dir") {
t.Errorf("activity log recorded denied path %q — denied paths must not be revealed", p)
}
}
}
// TestRawFilesHandler_TwoFilesOneDeniedServesArchive verifies the fix for the bug
// where a multi-file download request that has one path filtered out by access
// control would serve the remaining single file as a raw download instead of the
// requested archive. Two plain files are requested; one is denied; the surviving
// file must be returned inside a ZIP archive (not as a raw octet-stream with the
// plain filename), and the archive must contain only the allowed file.
func TestRawFilesHandler_TwoFilesOneDeniedServesArchive(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
// Create a plain file that owner is denied (DenyByDefault, no allow rule).
deniedDir := filepath.Join(sourceRoot, "denied_dir")
if err := os.MkdirAll(deniedDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deniedDir, "secret.txt"), []byte("secret-content"), 0o644); err != nil {
t.Fatal(err)
}
ownerUser := users.User{
FrontendUser: users.FrontendUser{Username: "owner"},
BackendScopes: []users.BackendScope{{
Path: sourceRoot,
Scope: "/",
Permissions: users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
},
}},
}
users.SyncBackendSourcePermissionsMap(&ownerUser)
// Request two plain files: one allowed (/shared/hello.txt), one denied (/denied_dir/secret.txt).
d := &Context{User: &ownerUser}
req := httptest.NewRequest(http.MethodGet, "/api/resources/download?source="+sourceName+"&file=/shared/hello.txt&file=/denied_dir/secret.txt&algo=zip", nil)
rec := httptest.NewRecorder()
status, err := RawFilesHandler(rec, req, d, sourceName, []string{"/shared/hello.txt", "/denied_dir/secret.txt"})
if err != nil {
t.Fatalf("two-files-one-denied: expected no error, got status=%d err=%v", status, err)
}
if status != http.StatusOK && status != 0 {
t.Fatalf("two-files-one-denied: expected 200, got status=%d", status)
}
// The response must be an archive, not a raw file. Check Content-Disposition for a .zip name.
cd := rec.Header().Get("Content-Disposition")
if !strings.Contains(cd, ".zip") {
t.Errorf("two-files-one-denied: Content-Disposition %q should reference a .zip archive (bug: single file served raw instead of archive)", cd)
}
body, readErr := io.ReadAll(rec.Body)
if readErr != nil {
t.Fatal(readErr)
}
names := readZipFileNames(body)
if len(names) == 0 {
t.Fatal("two-files-one-denied: body is not a valid ZIP archive (bug: raw file content served instead)")
}
// The allowed file must be present.
found := false
for _, n := range names {
if strings.HasSuffix(n, "hello.txt") {
found = true
break
}
}
if !found {
t.Fatalf("two-files-one-denied: zip %v should contain hello.txt from allowed path", names)
}
// The denied file must be absent.
for _, n := range names {
if strings.Contains(n, "secret.txt") {
t.Fatalf("two-files-one-denied: zip %v must not contain secret.txt from denied path", names)
}
}
}
// TestRawFilesHandler_TwoFilesAllDeniedReturns404 verifies that when all requested
// paths are access-denied the handler returns 404, indistinguishable from missing.
func TestRawFilesHandler_TwoFilesAllDeniedReturns404(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
deniedDir := filepath.Join(sourceRoot, "denied_dir")
if err := os.MkdirAll(deniedDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deniedDir, "a.txt"), []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
deniedDir2 := filepath.Join(sourceRoot, "denied_dir2")
if err := os.MkdirAll(deniedDir2, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deniedDir2, "b.txt"), []byte("b"), 0o644); err != nil {
t.Fatal(err)
}
ownerUser := users.User{
FrontendUser: users.FrontendUser{Username: "owner"},
BackendScopes: []users.BackendScope{{
Path: sourceRoot,
Scope: "/",
Permissions: users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
},
}},
}
users.SyncBackendSourcePermissionsMap(&ownerUser)
d := &Context{User: &ownerUser}
req := httptest.NewRequest(http.MethodGet, "/api/resources/download?source="+sourceName+"&file=/denied_dir/a.txt&file=/denied_dir2/b.txt&algo=zip", nil)
rec := httptest.NewRecorder()
status, err := RawFilesHandler(rec, req, d, sourceName, []string{"/denied_dir/a.txt", "/denied_dir2/b.txt"})
if err == nil {
t.Fatal("two-files-all-denied: expected error, got nil")
}
if status != http.StatusNotFound {
t.Fatalf("two-files-all-denied: expected 404, got status=%d err=%v", status, err)
}
}
// TestRawFilesHandler_SingleAllowedFileStillRaw verifies that a single-file download
// for an allowed path is still served as a raw file (not wrapped in an archive).
func TestRawFilesHandler_SingleAllowedFileStillRaw(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
settings.Config.Server.NameToSource[sourceName].Path = sourceRoot
ownerUser := users.User{
FrontendUser: users.FrontendUser{Username: "owner"},
BackendScopes: []users.BackendScope{{
Path: sourceRoot,
Scope: "/",
Permissions: users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
},
}},
}
users.SyncBackendSourcePermissionsMap(&ownerUser)
d := &Context{User: &ownerUser}
req := httptest.NewRequest(http.MethodGet, "/api/resources/download?source="+sourceName+"&file=/shared/hello.txt", nil)
rec := httptest.NewRecorder()
status, err := RawFilesHandler(rec, req, d, sourceName, []string{"/shared/hello.txt"})
if err != nil {
t.Fatalf("single-file: unexpected error: status=%d err=%v", status, err)
}
if status != http.StatusOK && status != 0 {
t.Fatalf("single-file: expected 200, got status=%d", status)
}
// Single-file response must NOT be a ZIP archive — Content-Disposition should
// not end in .zip and the body should be the raw file content.
cd := rec.Header().Get("Content-Disposition")
if strings.Contains(cd, ".zip") {
t.Errorf("single-file: Content-Disposition %q should not reference .zip for a raw single-file download", cd)
}
body, readErr := io.ReadAll(rec.Body)
if readErr != nil {
t.Fatal(readErr)
}
if string(body) != "share-zip-contents" {
t.Errorf("single-file: expected raw file content %q, got %q", "share-zip-contents", string(body))
}
}
+7 -4
View File
@@ -14,13 +14,12 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
// duplicateSearchMutex serializes duplicate searches to run one at a time
@@ -417,14 +416,18 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, stats
for _, fileInfo := range checksumGroup.Files {
// Remove the user scope from path
adjustedPath := "/" + strings.TrimPrefix(fileInfo.Path, opts.combinedPath)
resultGroup = append(resultGroup, &indexing.SearchResult{
result := &indexing.SearchResult{
Path: adjustedPath,
Source: opts.source,
Type: fileInfo.Type,
Size: fileInfo.Size,
Modified: fileInfo.ModTime.Format(time.RFC3339),
HasPreview: fileInfo.HasPreview,
})
}
if fileInfo.Created != nil {
result.Created = fileInfo.Created.Format(time.RFC3339)
}
resultGroup = append(resultGroup, result)
}
if len(resultGroup) >= 2 {
+2
View File
@@ -36,6 +36,7 @@ func configureHTTPRouter(router, api, publicRoutes, publicApi *http.ServeMux) {
// Auth Routes - /api/auth/
// ========================================
api.HandleFunc("POST /auth/login", withRateLimit(AuthRateLimitCredentialLockout, loginHelper(loginHandler)))
api.HandleFunc("POST /auth/password/change-required", withoutUser(withRateLimitChain(AuthRateLimitCredentialLockout, changeRequiredPasswordHandler)))
api.HandleFunc("POST /auth/logout", withOrWithoutUser(withRateLimitChain(AuthRateLimitModerate, logoutHandler)))
api.HandleFunc("POST /auth/signup", withoutUser(withRateLimitChain(AuthRateLimitModerate, signupHandler)))
api.HandleFunc("POST /auth/otp/generate", withOrWithoutUser(withRateLimitChain(AuthRateLimitModerate, generateOTPHandler)))
@@ -97,6 +98,7 @@ func configureHTTPRouter(router, api, publicRoutes, publicApi *http.ServeMux) {
api.HandleFunc("POST /access/group", withAdmin(groupPostHandler))
api.HandleFunc("PUT /access/group", withAdmin(groupPutHandler))
api.HandleFunc("DELETE /access/group", withAdmin(groupDeleteHandler))
api.HandleFunc("PUT /access/user-groups", withAdmin(userGroupsPutHandler))
// ========================================
// Share Routes - /api/share/
+48 -36
View File
@@ -158,56 +158,68 @@ func pickUserEntry(entries []*ldap.Entry) *ldap.Entry {
return user
}
// ldapGroupMatchesAdmin returns true if the LDAP group DN matches the configured admin group (full DN or CN value).
func ldapGroupMatchesAdmin(groupDN, adminGroup string) bool {
g := strings.TrimSpace(groupDN)
if g == adminGroup {
return true
}
dn, err := ldap.ParseDN(g)
if err != nil {
// ldapGroupMatches reports whether an LDAP group membership value matches a
// configured group. Matching is case-insensitive and accepts a full DN or a
// CN-only value. When both sides are DNs, the complete DN must match (case-
// insensitive); CN-only fallback applies only when one side is not a DN, so
// groups that share a CN under different parents cannot authorize each other.
func ldapGroupMatches(member, configured string) bool {
member = strings.TrimSpace(member)
configured = strings.TrimSpace(configured)
if configured == "" {
return false
}
for _, rdn := range dn.RDNs {
for _, attr := range rdn.Attributes {
if strings.EqualFold(attr.Type, "cn") && attr.Value == adminGroup {
return true
}
}
if strings.EqualFold(member, configured) {
return true
}
memberIsDN := ldapIsDN(member)
configuredIsDN := ldapIsDN(configured)
memberCN := ldapCN(member)
configuredCN := ldapCN(configured)
// Config CN-only vs member full DN.
if memberIsDN && !configuredIsDN && memberCN != "" && strings.EqualFold(memberCN, configured) {
return true
}
// Config full DN vs member CN-only.
if configuredIsDN && !memberIsDN && configuredCN != "" && strings.EqualFold(member, configuredCN) {
return true
}
return false
}
// ldapIsDN reports whether s parses as an LDAP distinguished name.
func ldapIsDN(s string) bool {
_, err := ldap.ParseDN(s)
return err == nil
}
// ldapCN returns the first CN RDN value from an LDAP DN string, or "" if none.
func ldapCN(s string) string {
dn, err := ldap.ParseDN(s)
if err != nil {
return ""
}
for _, rdn := range dn.RDNs {
for _, attr := range rdn.Attributes {
if strings.EqualFold(attr.Type, "cn") {
return attr.Value
}
}
}
return ""
}
// getOrCreateLdapUser returns the filebrowser user for an LDAP-authenticated username, creating one if configured.
// userGroups authorization is enforced in getOrCreateAuthenticatedUser via ldapGroupMatches.
func getOrCreateLdapUser(username string, groups []string) (*users.User, error) {
logger.Debugf("getting or creating ldap user %s", username)
ldapCfg := settings.Config.Auth.Methods.LdapAuth
// Check if user is in required groups (if userGroups is configured)
if len(ldapCfg.UserGroups) > 0 {
userInAllowedGroup := false
for _, allowedGroup := range ldapCfg.UserGroups {
for _, userGroup := range groups {
if ldapGroupMatchesAdmin(userGroup, allowedGroup) {
userInAllowedGroup = true
break
}
}
if userInAllowedGroup {
break
}
}
if !userInAllowedGroup {
logger.Warningf("User %s is not in any of the required groups %v. Access denied.", username, ldapCfg.UserGroups)
return nil, fmt.Errorf("user %s is not authorized to access this application (not in required groups)", username)
}
logger.Debugf("User %s is in required group, allowing access.", username)
}
isAdmin := false
if ldapCfg.AdminGroup != "" {
for _, g := range groups {
if ldapGroupMatchesAdmin(g, ldapCfg.AdminGroup) {
if ldapGroupMatches(g, ldapCfg.AdminGroup) {
isAdmin = true
logger.Debugf("User %s is in admin group %s, granting admin privileges.", username, ldapCfg.AdminGroup)
break
+44 -20
View File
@@ -90,61 +90,85 @@ func TestPickUserEntry(t *testing.T) {
}
}
func TestLdapGroupMatchesAdmin(t *testing.T) {
func TestLdapGroupMatches(t *testing.T) {
tests := []struct {
name string
groupDN string
adminGroup string
member string
configured string
want bool
}{
{
name: "exact DN match",
groupDN: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
adminGroup: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
member: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
configured: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
want: true,
},
{
name: "CN match",
groupDN: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
adminGroup: "authentik Admins",
member: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
configured: "authentik Admins",
want: true,
},
{
name: "CN match case-insensitive",
member: "cn=it department,ou=groups,dc=example,dc=com",
configured: "IT Department",
want: true,
},
{
name: "full DN match case-insensitive",
member: "CN=Employees,OU=Groups,DC=Example,DC=COM",
configured: "cn=employees,ou=groups,dc=example,dc=com",
want: true,
},
{
name: "config full DN vs member CN-only",
member: "Employees",
configured: "cn=Employees,ou=groups,dc=example,dc=com",
want: true,
},
{
name: "CN no match",
groupDN: "cn=Other Group,ou=groups,dc=test",
adminGroup: "authentik Admins",
member: "cn=Other Group,ou=groups,dc=test",
configured: "authentik Admins",
want: false,
},
{
name: "exact no match",
groupDN: "cn=admins,ou=groups,dc=test",
adminGroup: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
member: "cn=admins,ou=groups,dc=test",
configured: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
want: false,
},
{
name: "same CN different parents rejected",
member: "cn=Admins,ou=Other,dc=example,dc=com",
configured: "cn=Admins,ou=Privileged,dc=example,dc=com",
want: false,
},
{
name: "whitespace trimmed",
groupDN: " cn=admins,ou=groups,dc=test ",
adminGroup: "cn=admins,ou=groups,dc=test",
member: " cn=admins,ou=groups,dc=test ",
configured: "cn=admins,ou=groups,dc=test",
want: true,
},
{
name: "invalid DN returns false",
groupDN: "not-a-valid-dn",
adminGroup: "admins",
member: "not-a-valid-dn",
configured: "admins",
want: false,
},
{
name: "empty adminGroup no match",
groupDN: "cn=admins,ou=groups,dc=test",
adminGroup: "",
name: "empty configured no match",
member: "cn=admins,ou=groups,dc=test",
configured: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ldapGroupMatchesAdmin(tt.groupDN, tt.adminGroup)
got := ldapGroupMatches(tt.member, tt.configured)
if got != tt.want {
t.Errorf("ldapGroupMatchesAdmin(%q, %q) = %v, want %v", tt.groupDN, tt.adminGroup, got, tt.want)
t.Errorf("ldapGroupMatches(%q, %q) = %v, want %v", tt.member, tt.configured, got, tt.want)
}
})
}
+4
View File
@@ -5,8 +5,12 @@ import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
)
func TestMain(m *testing.M) {
if err := utils.SetInvalidPasswordHash(); err != nil {
panic(err)
}
os.Exit(testutil.Main(m))
}
+23 -35
View File
@@ -2,7 +2,6 @@ package web
import (
"bytes"
"errors"
"fmt"
"net/http"
"path/filepath"
@@ -242,7 +241,15 @@ func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *Context) (
func lyricsHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if path == "" || source == "" {
fileUser := d.User
if d.Share.Hash != "" {
source = d.Share.GetSourceName()
if source == "" {
return http.StatusNotFound, fmt.Errorf("source not found")
}
path = d.IndexPath
fileUser = d.ShareUser
} else if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
filePerms, err := effectiveFilePerms(d, source)
@@ -253,17 +260,22 @@ func lyricsHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
return http.StatusForbidden, fmt.Errorf("user is not allowed to view files in this source")
}
showHidden := d.User.ShowHidden
hideFileExt := d.User.HideFileExt
if d.Share.Hash != "" {
showHidden = d.Share.ShowHidden
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
FollowSymlinks: d.Share.Hash == "",
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: false,
ShowHidden: d.User.ShowHidden,
HideFileExt: d.User.HideFileExt,
ShowHidden: showHidden,
HideFileExt: hideFileExt,
SkipExtendedAttrs: false,
}, d.User)
}, fileUser)
if err != nil {
return ErrToStatus(err), err
}
@@ -289,34 +301,10 @@ func lyricsHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
// @Failure 404 {object} map[string]string "Not found"
// @Router /public/api/media/lyrics [get]
func publicLyricsHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
sourceCfg, ok := settings.Config.Server.SourceMap[d.Share.SourcePath]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
if status, err := publicShareMediaContentPolicy(d); err != nil {
return status, err
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: d.IndexPath,
Source: sourceCfg.Name,
Expand: true,
Content: false,
Metadata: false,
ShowHidden: d.Share.ShowHidden,
HideFileExt: d.User.HideFileExt,
FollowSymlinks: false,
SkipExtendedAttrs: false,
}, d.ShareUser)
if err != nil {
return ErrToStatus(err), err
}
if !strings.HasPrefix(fileInfo.Type, "audio") {
return http.StatusNotFound, fmt.Errorf("file is not an audio file")
}
lyrics, format, err := files.ExtractLyrics(fileInfo.RealPath)
if err != nil {
return http.StatusInternalServerError, errors.New("failed to extract lyrics")
}
return RenderJSON(w, r, map[string]any{"lyrics": lyrics, "format": format})
return lyricsHandler(w, r, d)
}
// publicSubtitlesHandler is the share-link variant of subtitlesHandler.
@@ -330,8 +318,8 @@ func publicLyricsHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
// @Success 200 {string} string "Raw subtitle content"
// @Router /public/api/media/subtitles [get]
func publicSubtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
if status, err := publicShareMediaContentPolicy(d); err != nil {
return status, err
}
return subtitlesHandler(w, r, d)
}
+96
View File
@@ -0,0 +1,96 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func publicMediaShareContext(owner *users.User, sourceRoot string, editable share.ShareEditable) *Context {
link := share.Share{
ShareColumns: share.ShareColumns{
Hash: "mediashare",
Path: "/shared",
},
FrontendShareInfo: editable.FrontendShareInfo,
ShareLimits: editable.ShareLimits,
SourcePath: sourceRoot,
}
return &Context{
User: &users.User{
FrontendUser: users.FrontendUser{Username: users.AnonymousUserName},
},
ShareUser: owner,
IndexPath: "/shared/song.mp3",
Share: link,
}
}
func TestPublicLyricsHandler_BlocksWhenFileViewerDisabled(t *testing.T) {
sourceRoot, _, owner := setupShareArchiveDownloadTest(t)
d := publicMediaShareContext(owner, sourceRoot, share.ShareEditable{DisableFileViewer: true})
req := httptest.NewRequest(http.MethodGet, "/public/api/media/lyrics?hash=mediashare&path=/song.mp3", nil)
rec := httptest.NewRecorder()
status, err := publicLyricsHandler(rec, req, d)
if err == nil {
t.Fatal("expected error when file viewer is disabled")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got status=%d err=%v", status, err)
}
}
func TestPublicLyricsHandler_BlocksWhenDownloadLimitReached(t *testing.T) {
sourceRoot, _, owner := setupShareArchiveDownloadTest(t)
d := publicMediaShareContext(owner, sourceRoot, share.ShareEditable{
DownloadsLimit: 1,
})
d.Share.Downloads = 1
req := httptest.NewRequest(http.MethodGet, "/public/api/media/lyrics?hash=mediashare&path=/song.mp3", nil)
rec := httptest.NewRecorder()
status, err := publicLyricsHandler(rec, req, d)
if err == nil {
t.Fatal("expected error when download limit is exhausted")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got status=%d err=%v", status, err)
}
}
func TestPublicSubtitlesHandler_BlocksWhenDownloadLimitReached(t *testing.T) {
sourceRoot, _, owner := setupShareArchiveDownloadTest(t)
d := publicMediaShareContext(owner, sourceRoot, share.ShareEditable{
DownloadsLimit: 1,
})
d.Share.Downloads = 1
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=mediashare&path=/movie.mp4&name=movie.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, err := publicSubtitlesHandler(rec, req, d)
if err == nil {
t.Fatal("expected error when download limit is exhausted")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got status=%d err=%v", status, err)
}
}
func TestPublicSubtitlesHandler_BlocksWhenDownloadDisabled(t *testing.T) {
sourceRoot, _, owner := setupShareArchiveDownloadTest(t)
d := publicMediaShareContext(owner, sourceRoot, share.ShareEditable{DisableDownload: true})
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=mediashare&path=/movie.mp4&name=movie.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, err := publicSubtitlesHandler(rec, req, d)
if err == nil {
t.Fatal("expected error when downloads are disabled")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got status=%d err=%v", status, err)
}
}
+14 -5
View File
@@ -71,8 +71,9 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if link.DisableAnonymous && data.User.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("share is not available to anonymous users")
}
// Block anonymous users if per-user download limit is enabled
if link.PerUserDownloadLimit && data.User.Username == "anonymous" {
// Block anonymous users from download routes when per-user download limits are enabled.
// View/stream routes still need view grants so anonymous visitors can play media inline.
if link.PerUserDownloadLimit && data.User.Username == "anonymous" && shareRequestAllowsDownload(r) {
return http.StatusForbidden, fmt.Errorf("anonymous downloads are not allowed with per-user limits")
}
if len(link.AllowedUsernames) > 0 {
@@ -439,12 +440,15 @@ func LoginHelper(disableOtp bool, fn handleFunc) handleFunc {
logger.Debug("ldap auth failed, calling password auth", err)
}
if settings.Config.Auth.Methods.PasswordAuth.Enabled {
user, err := auth.AuthenticatePassword(r, disableOtp)
user, err := auth.AuthenticatePassword(r, disableOtp, false)
if err != nil {
logger.Debug("password auth failed, calling handler:", err)
if err == errors.ErrNoTotpProvided {
return 403, err
}
if err == errors.ErrPasswordChangeRequired {
return 403, err
}
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
@@ -526,9 +530,14 @@ func withUserHelper(fn handleFunc) handleFunc {
}
data.User = userValue
// Set cookie. Some clients like gvfs relies on it for concurrent uploads
// Plant the session cookie only when the request didn't already carry
// one. Some clients like gvfs rely on it for concurrent uploads, but a
// request that presented a stale cookie must not re-emit it or it could
// revert the jar to a token retired by a concurrent renew.
if tk.RegisteredClaims.ExpiresAt != nil {
SetSessionCookie(w, r, data.Token, tk.RegisteredClaims.ExpiresAt.Time)
if c, err := r.Cookie(sessionCookieName); err != nil || c.Value == "" || c.Value != data.Token {
SetSessionCookie(w, r, data.Token, tk.RegisteredClaims.ExpiresAt.Time)
}
}
SetUserInResponseWriter(w, data.User)
if data.User.Username == "" {
+2
View File
@@ -18,9 +18,11 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"golang.org/x/crypto/bcrypt"
)
func setupTestEnv(t *testing.T) {
utils.BcryptCost = bcrypt.MinCost
dbPath := filepath.Join(t.TempDir(), "test.sqlite")
// Initialize state with SQLite database
@@ -0,0 +1,104 @@
package web
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
type changeRequiredPasswordBody struct {
Password string `json:"password"`
PasswordConfirm string `json:"passwordConfirm"`
}
// changeRequiredPasswordHandler sets a new password when RequirePasswordChange is enabled, then issues a session token.
// @Summary Change required password at login
// @Description Verifies the current password and sets a new password when requirePasswordChange is set. Issues a JWT on success.
// @Tags Auth
// @Accept json
// @Produce json
// @Param username query string true "Username"
// @Param X-Password header string true "URL-encoded current password"
// @Param X-Secret header string false "TOTP code (if 2FA is enabled)"
// @Param body body changeRequiredPasswordBody true "New password and confirmation"
// @Success 200 {string} string "JWT token for authentication"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 401 {object} map[string]string "Unauthorized"
// @Failure 403 {object} map[string]string "Forbidden"
// @Router /api/auth/password/change-required [post]
func changeRequiredPasswordHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
user, err := auth.AuthenticatePassword(r, false, true)
if err != nil {
if err == errors.ErrNoTotpProvided {
return http.StatusForbidden, err
}
return http.StatusUnauthorized, errors.ErrUnauthorized
}
if settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp && user.TOTPSecret == "" {
return http.StatusForbidden, errors.ErrNoTotpConfigured
}
if user.HasPasskeyMFA() && user.TOTPSecret == "" {
return http.StatusForbidden, errors.ErrPasskeyMFARequired
}
if user.LoginMethod != users.LoginMethodPassword {
return http.StatusBadRequest, fmt.Errorf("password change is only available for password login users")
}
if !user.RequirePasswordChange {
return http.StatusForbidden, fmt.Errorf("password change is not required for this user")
}
var body changeRequiredPasswordBody
if err = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid request body")
}
if body.Password == "" {
return http.StatusBadRequest, errors.ErrEmptyPassword
}
if body.Password != body.PasswordConfirm {
return http.StatusBadRequest, fmt.Errorf("passwords do not match")
}
if err = settings.ValidatePasswordPolicy(body.Password); err != nil {
return http.StatusBadRequest, err
}
currentPassword := r.Header.Get("X-Password")
currentPassword, decErr := url.QueryUnescape(currentPassword)
if decErr != nil {
return http.StatusBadRequest, fmt.Errorf("invalid password encoding")
}
if err = utils.CheckPwd(currentPassword, user.Password); err != nil {
return http.StatusUnauthorized, errors.ErrUnauthorized
}
if body.Password == currentPassword {
return http.StatusBadRequest, fmt.Errorf("new password must differ from the current password")
}
patch := *user
patch.RequirePasswordChange = false
if err = state.UpdateUser(&patch, body.Password, "password", "requirePasswordChange"); err != nil {
return http.StatusInternalServerError, err
}
updated, getErr := state.GetUserByUsername(user.Username)
if getErr != nil {
return http.StatusInternalServerError, getErr
}
d.User = &updated
status, err := printToken(w, r, d.User, "")
if err != nil || status != 0 {
return status, err
}
activity.RecordLogin(r, d.User)
return 0, nil
}
@@ -0,0 +1,88 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestLoginBlocksRequirePasswordChange(t *testing.T) {
setupTestEnv(t)
origPasswordAuth := settings.Config.Auth.Methods.PasswordAuth.Enabled
settings.Config.Auth.Methods.PasswordAuth.Enabled = true
t.Cleanup(func() { settings.Config.Auth.Methods.PasswordAuth.Enabled = origPasswordAuth })
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "mustchange",
LoginMethod: users.LoginMethodPassword,
RequirePasswordChange: true,
},
}
if err := state.CreateUser(user, "tempPass"); err != nil {
t.Fatalf("create user: %v", err)
}
handler := wrapHandler(loginHelper(loginHandler))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login?username=mustchange", nil)
req.Header.Set("X-Password", "tempPass")
handler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("login status = %d, want 403", rec.Code)
}
}
func TestChangeRequiredPasswordHandler(t *testing.T) {
setupTestEnv(t)
origPasswordAuth := settings.Config.Auth.Methods.PasswordAuth.Enabled
settings.Config.Auth.Methods.PasswordAuth.Enabled = true
t.Cleanup(func() { settings.Config.Auth.Methods.PasswordAuth.Enabled = origPasswordAuth })
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "mustchange2",
LoginMethod: users.LoginMethodPassword,
RequirePasswordChange: true,
},
}
if err := state.CreateUser(user, "tempPass"); err != nil {
t.Fatalf("create user: %v", err)
}
body, _ := json.Marshal(map[string]string{
"password": "newSecurePass",
"passwordConfirm": "newSecurePass",
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/password/change-required?username=mustchange2", bytes.NewReader(body))
req.Header.Set("X-Password", "tempPass")
req.Header.Set("Content-Type", "application/json")
handler := wrapHandler(withoutUserHelper(changeRequiredPasswordHandler))
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("change-required status = %d body = %s", rec.Code, rec.Body.String())
}
updated, err := state.GetUserByUsername("mustchange2")
if err != nil {
t.Fatal(err)
}
if updated.RequirePasswordChange {
t.Fatal("expected requirePasswordChange cleared")
}
loginReq := httptest.NewRequest(http.MethodPost, "/?username=mustchange2", nil)
loginReq.Header.Set("X-Password", "newSecurePass")
if _, err = auth.AuthenticatePassword(loginReq, true, false); err != nil {
t.Fatalf("login with new password: %v", err)
}
}
+11 -2
View File
@@ -8,12 +8,11 @@ import (
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
type searchOptions struct {
@@ -70,6 +69,7 @@ type scopedSourcePath struct {
// @Param olderThan query int false "Unix seconds; only results modified strictly before this time"
// @Param newerThan query int false "Unix seconds; only results modified on or after this time"
// @Param useWildcard query bool false "When true, match indexed file names with SQLite GLOB (wildcard patterns)"
// @Param limit query int false "Requested advanced search limit, capped by server.searchResultsLimit (default 1000); omitted for quick search (100)"
// @Param termJoin query string false "Optional: 'and' to require all repeated 'terms' match; default is OR"
// @Success 200 {array} indexing.SearchResult "List of search results with source field populated"
// @Failure 400 {object} map[string]string "Bad Request"
@@ -83,6 +83,15 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
searchSize := indexing.DefaultSearchResults
if searchOptions.largest {
searchSize = 200
} else if rawLimit := r.URL.Query().Get("limit"); rawLimit != "" {
requested, err := strconv.Atoi(rawLimit)
if err != nil || requested < 1 {
return http.StatusBadRequest, fmt.Errorf("limit must be a positive integer")
}
searchSize = requested
if searchSize > settings.Config.Server.SearchResultsLimit {
searchSize = settings.Config.Server.SearchResultsLimit
}
}
var response []*indexing.SearchResult
+95
View File
@@ -0,0 +1,95 @@
package web
import (
"encoding/json"
"fmt"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestSearchHandlerConfiguredLimit(t *testing.T) {
original := settings.Config
originalDB := indexing.GetIndexDB()
t.Cleanup(func() { settings.Config = original; indexing.SetIndexDBForTesting(originalDB) })
settings.Config = settings.SetDefaults(true)
setupTestEnv(t)
settings.Config.Server.CacheDir = t.TempDir()
fileutils.SetFsPermissions(0644, 0755)
db, _, err := dbsql.NewIndexDB("search_limit", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
indexing.SetIndexDBForTesting(db)
user := &users.User{FrontendUser: users.FrontendUser{Username: "search-user"}}
for _, name := range []string{"limit-a", "limit-b"} {
source := &settings.Source{Name: name, Path: filepath.Join(t.TempDir(), name)}
source.Config.ResolvedRules.IndexingDisabled = true
settings.Config.Server.NameToSource[name] = source
settings.Config.Server.SourceMap[source.Path] = source
user.BackendScopes = append(user.BackendScopes, users.BackendScope{Path: source.Path, Scope: "/"})
indexing.Initialize(source, true, false)
files := make([]iteminfo.ExtendedItemInfo, 600)
for i := range files {
files[i] = iteminfo.ExtendedItemInfo{ItemInfo: iteminfo.ItemInfo{Name: fmt.Sprintf("document-%03d.txt", i), Type: "text", Size: 1024}}
}
indexing.GetIndex(name).UpdateMetadata(&iteminfo.FileInfo{Path: "/", Files: files}, nil, true)
}
for _, tc := range []struct {
name string
limit int
sources string
largest bool
requested string
want int
}{
{"quick default", 1000, "limit-a", false, "", 100},
{"advanced default", 1000, "limit-a", false, "500", 500},
{"custom request", 1000, "limit-a", false, "250", 250},
{"minimum request", 1000, "limit-a", false, "1", 1},
{"server cap", 250, "limit-a", false, "500", 250},
{"quick unaffected by advanced cap", 20, "limit-a", false, "", 100},
{"combined limit", 1000, "limit-a,limit-b", false, "1000", 1000},
{"combined capped", 400, "limit-a,limit-b", false, "900", 400},
{"size viewer unchanged", 10, "limit-a", true, "1", 200},
} {
t.Run(tc.name, func(t *testing.T) {
settings.Config.Server.SearchResultsLimit = tc.limit
req := httptest.NewRequest("GET", fmt.Sprintf("/api/tools/search?query=document&sources=%s&largest=%t&limit=%s", tc.sources, tc.largest, tc.requested), nil)
recorder := httptest.NewRecorder()
status, err := searchHandler(recorder, req, &Context{User: user})
if err != nil {
t.Fatalf("status %d: %v", status, err)
}
var results []indexing.SearchResult
if err := json.Unmarshal(recorder.Body.Bytes(), &results); err != nil {
t.Fatal(err)
}
if len(results) != tc.want {
t.Fatalf("want %d results, got %d", tc.want, len(results))
}
})
}
for _, limit := range []string{"0", "-1", "abc", "1.5", "9999999999999999999999999999"} {
t.Run("invalid "+limit, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/tools/search?query=document&sources=limit-a&limit="+limit, nil)
status, err := searchHandler(httptest.NewRecorder(), req, &Context{User: user})
if status != 400 || err == nil || err.Error() != "limit must be a positive integer" {
t.Fatalf("expected invalid limit, got status=%d err=%v", status, err)
}
})
}
}
+346
View File
@@ -1,13 +1,92 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestSessionTokensRemainIndependent(t *testing.T) {
setupTestEnv(t)
origKey := settings.Config.Auth.Key
origExp := settings.Config.Auth.TokenExpirationHours
settings.Config.Auth.Key = "test-signing-key-for-independent-sessions"
settings.Config.Auth.TokenExpirationHours = 2
t.Cleanup(func() {
settings.Config.Auth.Key = origKey
settings.Config.Auth.TokenExpirationHours = origExp
})
user := createTokenAuthUser(t, "session-user", users.Permissions{Api: true})
admin := createTokenAuthUser(t, "session-admin", users.Permissions{Admin: true, Api: true})
owners := []*users.User{user, user, admin}
tokens := make([]string, len(owners))
for i, owner := range owners {
var err error
tokens[i], err = mintAndRegisterSessionToken(owner)
if err != nil {
t.Fatal(err)
}
}
for i, token := range tokens {
if userID, isSession, ok := state.HashedTokenOwner(token); !ok || !isSession || userID != owners[i].ID {
t.Fatalf("session %d lost its owner: userID=%d isSession=%v ok=%v", i, userID, isSession, ok)
}
want := http.StatusForbidden
if owners[i].Permissions.Admin {
want = http.StatusOK
}
if rec := cookieRequest(t, withAdmin(mockHandler), token); rec.Code != want {
t.Fatalf("session %d: got status %d, want %d", i, rec.Code, want)
}
}
if err := state.RevokeToken(tokens[0]); err != nil {
t.Fatal(err)
}
for i, token := range tokens {
want := http.StatusOK
if i == 0 {
want = http.StatusUnauthorized
}
if rec := cookieRequest(t, withUser(mockHandler), token); rec.Code != want {
t.Fatalf("session %d after revocation: got status %d, want %d", i, rec.Code, want)
}
}
}
func TestSessionTokenWithoutIDRemainsValid(t *testing.T) {
setupTestEnv(t)
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-signing-key-for-legacy-session"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
user := createTokenAuthUser(t, "legacy-session-user", users.Permissions{Api: true})
now := time.Now()
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
Issuer: auth.FB_ISSUER,
}).SignedString([]byte(settings.Config.Auth.Key))
if err != nil {
t.Fatal(err)
}
if err := state.RegisterSessionToken(token, user.ID); err != nil {
t.Fatal(err)
}
if rec := cookieRequest(t, withUser(mockHandler), token); rec.Code != http.StatusOK {
t.Fatalf("legacy session: got status %d, want %d", rec.Code, http.StatusOK)
}
}
func TestReplaceSessionTokenKeepsOldTokenWhenMintFails(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "rotation-user", users.Permissions{Api: true})
@@ -50,3 +129,270 @@ func TestReplaceSessionTokenRetiresOldWithGrace(t *testing.T) {
t.Fatal("old token mapping must remain during the grace window")
}
}
// Regression test for #3006: a request that already carried the session cookie
// must not re-emit Set-Cookie. Otherwise an in-flight response carrying a
// just-rotated-out token reverts the browser jar to a token that dies when its
// retirement grace expires.
func TestWithUserDoesNotEchoCookieToken(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "echo-user", users.Permissions{Api: true})
token := testSessionToken(t, user, time.Hour)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/users", http.NoBody)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
withUser(mockHandler)(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
if setCookie := recorder.Header().Values("Set-Cookie"); len(setCookie) != 0 {
t.Fatalf("cookie-carried request must not re-emit Set-Cookie, got %v", setCookie)
}
}
// Clients that authenticate without a session cookie (gvfs, Bearer, ?auth=)
// still get the token planted so subsequent requests stay authenticated.
func TestWithUserPlantsCookieForBearerClients(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "bearer-user", users.Permissions{Api: true})
token := testSessionToken(t, user, time.Hour)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/users", http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
withUser(mockHandler)(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
cookies := recorder.Result().Cookies()
var session *http.Cookie
for _, c := range cookies {
if c.Name == sessionCookieName {
session = c
}
}
if session == nil {
t.Fatal("cookie-less authenticated request must receive Set-Cookie")
}
if session.Value != token {
t.Fatal("planted cookie must contain the presented token")
}
}
func TestRenewHandlerNoopWhenNoAuth(t *testing.T) {
setupTestEnv(t)
orig := settings.Config.Auth.Methods.NoAuth
settings.Config.Auth.Methods.NoAuth = true
t.Cleanup(func() { settings.Config.Auth.Methods.NoAuth = orig })
user := createTokenAuthUser(t, "noauth-renew", users.Permissions{Admin: true})
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/renew", http.NoBody)
status, err := renewHandler(recorder, req, &requestContext{User: user, Token: ""})
if err != nil {
t.Fatalf("renewHandler: %v", err)
}
if status != 0 && status != http.StatusOK {
t.Fatalf("expected success status, got %d", status)
}
}
// Restricted API tokens must not call renew: rotation registers a session hash
// and would grant the token owner's full permissions (GHSA-6gr6-5qpq-888p).
func TestRenewHandlerRejectsApiToken(t *testing.T) {
setupTestEnv(t)
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
user := createTokenAuthUser(t, "renew-api-user", users.Permissions{Admin: true, Api: true})
tokenString, tokenMeta, err := auth.MakeSignedTokenAPI(user, "capped-key", time.Hour, users.Permissions{Api: true}, false)
if err != nil {
t.Fatal(err)
}
tokenMeta.Name = "capped-key"
tokenMeta.Token = tokenString
if err = state.AddUserToken(user.Username, tokenMeta); err != nil {
t.Fatal(err)
}
if err = state.AddApiToken(tokenString, user.ID); err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/renew", http.NoBody)
req.Header.Set("Authorization", "Bearer "+tokenString)
status, err := renewHandler(recorder, req, &requestContext{User: user, Token: tokenString})
if err == nil {
t.Fatal("expected renew to fail for API token")
}
if status != http.StatusForbidden {
t.Fatalf("expected status %d, got %d", http.StatusForbidden, status)
}
if _, isSession, ok := state.HashedTokenOwner(tokenString); !ok || isSession {
t.Fatalf("API token must remain non-session: ok=%v isSession=%v", ok, isSession)
}
}
// Renew while the presented token is still fresh returns it unchanged instead
// of rotating: routine page loads must not churn session-token state.
func TestRenewHandlerFreshTokenIsIdempotent(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "renew-fresh-user", users.Permissions{Api: true})
token := testSessionToken(t, user, 2*time.Hour)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/renew", http.NoBody)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
status, err := renewHandler(recorder, req, &requestContext{User: user, Token: token})
if err != nil {
t.Fatalf("renewHandler: %v", err)
}
if status != 0 && status != http.StatusOK {
t.Fatalf("expected success status, got %d", status)
}
if got := recorder.Body.String(); got != token {
t.Fatal("renew on a fresh token must return the same token")
}
if state.IsTokenRevoked(token) {
t.Fatal("fresh token must not be retired by a no-op renew")
}
if setCookie := recorder.Header().Values("Set-Cookie"); len(setCookie) != 0 {
t.Fatalf("idempotent renew with matching cookie must not re-emit Set-Cookie, got %v", setCookie)
}
}
func TestRenewHandlerFreshTokenPlantsCookieWithoutJar(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "renew-fresh-no-cookie", users.Permissions{Api: true})
token := testSessionToken(t, user, 2*time.Hour)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/renew", http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
status, err := renewHandler(recorder, req, &requestContext{User: user, Token: token})
if err != nil {
t.Fatalf("renewHandler: %v", err)
}
if status != 0 && status != http.StatusOK {
t.Fatalf("expected success status, got %d", status)
}
if len(recorder.Header().Values("Set-Cookie")) == 0 {
t.Fatal("idempotent renew without session cookie must plant Set-Cookie")
}
}
// Concurrent renew ordering: an in-flight idempotent renew must not revert a
// jar that a later rotation already upgraded.
func TestRenewHandlerIdempotentDoesNotRevertRotatedJar(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "renew-order-user", users.Permissions{Api: true})
oldToken := testSessionToken(t, user, 2*time.Hour)
newToken, err := replaceSessionToken(oldToken, user)
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/renew", http.NoBody)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: oldToken})
status, err := renewHandler(recorder, req, &requestContext{User: user, Token: oldToken})
if err != nil {
t.Fatalf("renewHandler: %v", err)
}
if status != 0 && status != http.StatusOK {
t.Fatalf("expected success status, got %d", status)
}
if got := recorder.Body.String(); got != oldToken {
t.Fatal("idempotent renew must still return the presented token")
}
if setCookie := recorder.Header().Values("Set-Cookie"); len(setCookie) != 0 {
t.Fatalf("must not Set-Cookie old token after jar already rotated, got %v", setCookie)
}
if _, isSession, ok := state.HashedTokenOwner(newToken); !ok || !isSession {
t.Fatalf("rotated token must remain registered: ok=%v isSession=%v", ok, isSession)
}
}
func TestWithUserRefreshesCookieWhenBearerMismatchesJar(t *testing.T) {
setupTestEnv(t)
origExp := settings.Config.Auth.TokenExpirationHours
t.Cleanup(func() { settings.Config.Auth.TokenExpirationHours = origExp })
settings.Config.Auth.TokenExpirationHours = 2
user := createTokenAuthUser(t, "bearer-mismatch-user", users.Permissions{Api: true})
stale := testSessionToken(t, user, time.Hour)
fresh, err := replaceSessionToken(stale, user)
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/users", http.NoBody)
req.Header.Set("Authorization", "Bearer "+fresh)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: stale})
withUser(mockHandler)(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d body=%s", recorder.Code, recorder.Body.String())
}
cookies := recorder.Result().Cookies()
var session *http.Cookie
for _, c := range cookies {
if c.Name == sessionCookieName {
session = c
}
}
if session == nil {
t.Fatalf("Bearer auth with stale cookie jar must refresh Set-Cookie, headers=%v", recorder.Header().Values("Set-Cookie"))
}
if session.Value != fresh {
t.Fatal("planted cookie must match the Bearer token")
}
}
// Inside the refresh window renew rotates: new token registered, old retired
// but still resolvable during the grace window.
func TestRenewHandlerNearExpiryRotates(t *testing.T) {
setupTestEnv(t)
origExp := settings.Config.Auth.TokenExpirationHours
t.Cleanup(func() { settings.Config.Auth.TokenExpirationHours = origExp })
settings.Config.Auth.TokenExpirationHours = 2
user := createTokenAuthUser(t, "renew-rotate-user", users.Permissions{Api: true})
token := testSessionToken(t, user, 10*time.Minute)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/renew", http.NoBody)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
status, err := renewHandler(recorder, req, &requestContext{User: user, Token: token})
if err != nil {
t.Fatalf("renewHandler: %v", err)
}
if status != 0 && status != http.StatusOK {
t.Fatalf("expected success status, got %d", status)
}
newToken := recorder.Body.String()
if newToken == token {
t.Fatal("renew inside the refresh window must rotate the token")
}
if _, isSession, ok := state.HashedTokenOwner(newToken); !ok || !isSession {
t.Fatalf("rotated token must be registered as a session: ok=%v isSession=%v", ok, isSession)
}
if state.IsTokenRevoked(token) {
t.Fatal("rotated-out token must remain valid during the grace window")
}
}
+9
View File
@@ -224,6 +224,15 @@ func shareRequestAllowsDownloadToken(r *http.Request) bool {
return shareRouteAllowsDownloadToken(r.URL.Path)
}
// shareRequestAllowsDownload reports metered download routes (not inline view/stream).
func shareRequestAllowsDownload(r *http.Request) bool {
if r == nil {
return false
}
path := r.URL.Path
return strings.Contains(path, "/resources/download") || strings.Contains(path, "/raw")
}
// directDownloadURL builds a public download URL with an ephemeral token query parameter.
func directDownloadURL(host, scheme, hash, token string) string {
tokenParam := ""
+2
View File
@@ -270,6 +270,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
data["globalVars"] = map[string]interface{}{
"name": settings.Config.Frontend.Name,
"minSearchLength": settings.Config.Server.MinSearchLength,
"searchResultsLimit": settings.Config.Server.SearchResultsLimit,
"disableExternal": settings.Config.Frontend.DisableDefaultLinks,
"darkMode": settings.Config.UserDefaults.UI.DarkMode,
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
@@ -287,6 +288,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"jwtAvailable": settings.Config.Auth.Methods.JwtAuth.Enabled,
"proxyAvailable": settings.Config.Auth.Methods.ProxyAuth.Enabled,
"passwordAvailable": settings.Config.Auth.Methods.PasswordAuth.Enabled,
"passwordMinLength": settings.PasswordMinLength(),
"ldapAvailable": settings.Config.Auth.Methods.LdapAuth.Enabled,
"mediaAvailable": settings.MediaEnabled(),
"muPdfAvailable": settings.Env.MuPdfAvailable,
+75 -5
View File
@@ -232,18 +232,19 @@ func ValidateViewGrant(token string, d *Context, sourceName string) error {
utils.ViewGrantsCache.Delete(token)
return fmt.Errorf("view token expired")
}
if grant.Source != viewGrantScope(d, sourceName) {
viewCtx := contextForViewGrantValidation(d, grant.Source)
if !viewGrantScopeMatches(viewCtx, grant.Source, sourceName) {
return fmt.Errorf("view token scope mismatch")
}
internalSource := sourceName
if d.Share.Hash != "" {
if viewCtx.Share.Hash != "" {
var err error
internalSource, err = shareSourceName(d)
internalSource, err = shareSourceName(viewCtx)
if err != nil {
return err
}
}
perms, err := effectiveFilePerms(d, internalSource)
perms, err := effectiveFilePerms(viewCtx, internalSource)
if err != nil || !perms.View {
return fmt.Errorf("view permission required")
}
@@ -252,6 +253,69 @@ func ValidateViewGrant(token string, d *Context, sourceName string) error {
return nil
}
// contextForViewGrantValidation hydrates share metadata when optional-auth middleware
// left only a hash stub on the context (anonymous public share visitors).
func contextForViewGrantValidation(d *Context, grantSource string) *Context {
if d == nil {
return d
}
if d.Share.Hash != "" {
return d
}
hash := strings.TrimSpace(grantSource)
if hash == "" {
return d
}
link, err := state.GetShare(hash)
if err != nil || link.Hash == "" {
return d
}
out := *d
out.Share = link
if out.ShareUser == nil {
if u, err := state.UserForShareOwner(link); err == nil {
out.ShareUser = &u
}
}
return &out
}
func viewGrantScopeMatches(d *Context, grantSource, sourceName string) bool {
expected := viewGrantScope(d, sourceName)
if grantSource == expected {
return true
}
if d == nil || d.Share.Hash == "" {
return false
}
internalSource, err := shareSourceName(d)
if err != nil {
return false
}
// Accept source-scoped grants on share routes (e.g. minted before share context was hydrated).
return grantSource == internalSource && expected == d.Share.Hash
}
// hydratePublicShareContext ensures public share handlers have full share + owner context.
func hydratePublicShareContext(d *Context, r *http.Request) {
if d == nil || r == nil {
return
}
if d.Share.Hash == "" {
hash := strings.TrimSpace(r.URL.Query().Get("hash"))
if hash != "" {
if link, err := state.GetShare(hash); err == nil {
d.Share = link
}
}
}
if d.Share.Hash != "" && d.ShareUser == nil {
if u, err := state.UserForShareOwner(d.Share); err == nil {
d.ShareUser = &u
}
}
}
func canMintViewToken(d *Context, source string) bool {
if d.Share.Hash != "" && d.Share.ShareType == "upload" {
return false
@@ -488,7 +552,12 @@ func ServeSingleFile(w http.ResponseWriter, r *http.Request, d *Context, source
}
SetContentDisposition(w, r, displayFileName, opts.ForceInline)
w.Header().Set("Cache-Control", "private")
// no-cache: the URL does not change when the file does, so browsers must revalidate
// (ServeContent answers 304 when unchanged) instead of reusing a stale copy. The ETag,
// from mtime in nanoseconds and size, catches an edit within the same second, which
// If-Modified-Since cannot; it costs no read of the file.
w.Header().Set("Cache-Control", "private, no-cache")
w.Header().Set("ETag", fmt.Sprintf(`"%x-%x"`, fileInfo.ModTime().UnixNano(), fileInfo.Size()))
w.Header().Set("X-Content-Type-Options", "nosniff")
var reader io.ReadSeeker = fd
@@ -583,6 +652,7 @@ func streamHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/api/media/stream [get]
func publicStreamHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
hydratePublicShareContext(d, r)
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("streaming is disabled for upload shares")
}
+27
View File
@@ -801,3 +801,30 @@ func TestViewTokenHandlerExtendsExistingToken(t *testing.T) {
t.Fatalf("expected extended expiry, before=%d after=%d", almostExpired, grantAfter.ExpiresAt)
}
}
func TestValidateViewGrantSourceScopedGrantOnPublicShare(t *testing.T) {
initStreamTestSources(t)
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/srv": {Path: "/srv", Name: "srv"},
}
token, err := utils.RandomHex(16)
if err != nil {
t.Fatal(err)
}
utils.ViewGrantsCache.Set(token, utils.ViewGrant{
Source: "srv",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
})
d := &requestContext{
User: &users.User{
FrontendUser: users.FrontendUser{Username: users.AnonymousUserName},
},
Share: share.Share{
ShareColumns: share.ShareColumns{Hash: "sharehash"},
SourcePath: "/srv",
},
}
if err := ValidateViewGrant(token, d, ""); err != nil {
t.Fatalf("ValidateViewGrant: %v", err)
}
}
+52 -20
View File
@@ -6,6 +6,7 @@ import (
"net/url"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
)
@@ -23,14 +24,17 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/otp/generate [post]
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
err := checkPassword(r, d)
if err != nil {
return http.StatusUnauthorized, err
}
user, getErr := state.GetUserByUsername(r.URL.Query().Get("username"))
targetUsername := r.URL.Query().Get("username")
user, getErr := state.GetUserByUsername(targetUsername)
if getErr != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
}
if status, err := requireOtpEnrollmentAuthorized(&user, d); err != nil {
return status, err
}
if err := checkOtpActorPassword(r, d, user.Username); err != nil {
return http.StatusUnauthorized, err
}
url, err := auth.GenerateOtpForUser(&user)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
@@ -60,16 +64,18 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
if code == "" {
return http.StatusUnauthorized, fmt.Errorf("code is required")
}
err := checkPassword(r, d)
if err != nil {
return http.StatusUnauthorized, err
}
user, getErr := state.GetUserByUsername(r.URL.Query().Get("username"))
targetUsername := r.URL.Query().Get("username")
user, getErr := state.GetUserByUsername(targetUsername)
if getErr != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
}
err = auth.VerifyTotpCode(&user, code)
if err != nil {
if status, err := requireOtpEnrollmentAuthorized(&user, d); err != nil {
return status, err
}
if err := checkOtpActorPassword(r, d, user.Username); err != nil {
return http.StatusUnauthorized, err
}
if err := auth.VerifyTotpCode(&user, code); err != nil {
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
}
if err := state.UpdateUser(&user, "", "TOTPSecret", "TOTPNonce", "OtpEnabled"); err != nil {
@@ -83,13 +89,40 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return RenderJSON(w, r, response)
}
func checkPassword(r *http.Request, d *Context) error {
providedPassword := r.Header.Get("X-Password")
username := r.URL.Query().Get("username")
if d.User.Permissions.Admin {
username = d.User.Username
// userConfiguredMFA reports whether the account already has a second factor that must not be reset anonymously.
func userConfiguredMFA(u *users.User) bool {
if u == nil {
return false
}
if u.HasPasskeyMFA() {
return true
}
return u.TOTPSecret != ""
}
// requireOtpEnrollmentAuthorized allows password-only OTP enrollment only for first-time setup (no MFA).
// Replacing or adding TOTP when MFA exists requires an authenticated self or admin session.
func requireOtpEnrollmentAuthorized(target *users.User, d *Context) (int, error) {
if !userConfiguredMFA(target) {
return 0, nil
}
if d.User == nil || d.User.Username == "" || d.User.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("authentication required to reset two-factor authentication")
}
if d.User.Permissions.Admin || d.User.Username == target.Username {
return 0, nil
}
return http.StatusForbidden, fmt.Errorf("not authorized to reset two-factor authentication for this user")
}
// checkOtpActorPassword verifies X-Password for the account that must prove knowledge of the password.
// For admin sessions resetting another user, the admin's password is checked.
func checkOtpActorPassword(r *http.Request, d *Context, targetUsername string) error {
providedPassword := r.Header.Get("X-Password")
actorUsername := targetUsername
if d.User != nil && d.User.Permissions.Admin && d.User.Username != targetUsername {
actorUsername = d.User.Username
}
// url decode
providedPassword, err := url.QueryUnescape(providedPassword)
if err != nil {
return fmt.Errorf("invalid password encoding: %v", err)
@@ -97,14 +130,13 @@ func checkPassword(r *http.Request, d *Context) error {
if providedPassword == "" {
return fmt.Errorf("password is required")
}
user, getErr := state.GetUserByUsername(username)
user, getErr := state.GetUserByUsername(actorUsername)
var passwordHash string
if getErr != nil {
passwordHash = utils.InvalidPasswordHash
} else {
passwordHash = user.Password
}
// always run checkPwd to prevent timing attacks
err = utils.CheckPwd(providedPassword, passwordHash)
if err != nil {
return fmt.Errorf("invalid password or user not found")
+67 -6
View File
@@ -24,7 +24,15 @@ func otpRequest(username, password, code, path string) *http.Request {
return req
}
func TestVerifyOTP_CachedSecretTakesPrecedence(t *testing.T) {
func anonymousContext() *Context {
u := &users.User{
FrontendUser: users.FrontendUser{Username: "anonymous"},
}
state.ApplyUserDefaults(u)
return &Context{User: u}
}
func TestVerifyOTP_CachedSecretTakesPrecedenceWhenAuthenticatedSelf(t *testing.T) {
setupTestEnv(t)
const oldSecret = "SOMEOLDSECRET234"
@@ -41,15 +49,19 @@ func TestVerifyOTP_CachedSecretTakesPrecedence(t *testing.T) {
}
t.Cleanup(func() { auth.TotpCache.Delete(user.Username) })
d := &Context{User: user}
reloaded, reloadErr := state.GetUserByUsername(user.Username)
if reloadErr != nil {
t.Fatalf("failed to reload user: %v", reloadErr)
}
d := &Context{User: &reloaded}
rec := httptest.NewRecorder()
if _, err := generateOTPHandler(rec, otpRequest(user.Username, "testPass", "", "/api/auth/otp/generate"), d); err != nil {
t.Fatalf("generation failed: %v", err)
if _, genErr := generateOTPHandler(rec, otpRequest(user.Username, "testPass", "", "/api/auth/otp/generate"), d); genErr != nil {
t.Fatalf("generation failed: %v", genErr)
}
var resp map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
t.Fatalf("failed to decode response: %v", unmarshalErr)
}
u, err := url.Parse(resp["url"])
if err != nil {
@@ -85,6 +97,55 @@ func TestVerifyOTP_CachedSecretTakesPrecedence(t *testing.T) {
}
}
func TestRequireOtpEnrollmentAuthorized(t *testing.T) {
withMFA := &users.User{
FrontendUser: users.FrontendUser{Username: "victim"},
TOTPSecret: "SOMEOLDSECRET234",
}
status, err := requireOtpEnrollmentAuthorized(withMFA, anonymousContext())
if err == nil {
t.Fatal("expected anonymous MFA reset to be rejected")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got %d err=%v", status, err)
}
noMFA := &users.User{
FrontendUser: users.FrontendUser{Username: "new"},
}
if status, err = requireOtpEnrollmentAuthorized(noMFA, anonymousContext()); err != nil {
t.Fatalf("expected first-time enrollment to be allowed: status=%d err=%v", status, err)
}
self := &users.User{
FrontendUser: users.FrontendUser{Username: "victim"},
}
if status, err = requireOtpEnrollmentAuthorized(withMFA, &Context{User: self}); err != nil {
t.Fatalf("expected self reset to be allowed: status=%d err=%v", status, err)
}
admin := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
Permissions: users.Permissions{Admin: true},
},
}
if status, err = requireOtpEnrollmentAuthorized(withMFA, &Context{User: admin}); err != nil {
t.Fatalf("expected admin reset to be allowed: status=%d err=%v", status, err)
}
other := &users.User{
FrontendUser: users.FrontendUser{Username: "other"},
}
status, err = requireOtpEnrollmentAuthorized(withMFA, &Context{User: other})
if err == nil {
t.Fatal("expected non-admin other user to be rejected")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got %d err=%v", status, err)
}
}
func TestLogin_RequiresOTPWhenTOTPSecretSet(t *testing.T) {
setupTestEnv(t)
+1
View File
@@ -82,6 +82,7 @@ func viewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/api/resources/view [get]
func PublicViewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
hydratePublicShareContext(d, r)
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("viewing is disabled for upload shares")
}
+49 -5
View File
@@ -51,11 +51,11 @@ func (f *fileInfoWrapper) mode() os.FileMode {
// filteredFileSystem wraps a webdav.FileSystem and filters directory listings using FileInfoFaster
type filteredFileSystem struct {
fs webdav.FileSystem
source string
user *users.User
filePerms users.SourceFilePermissions
httpReq *http.Request
fs webdav.FileSystem
source string
user *users.User
filePerms users.SourceFilePermissions
httpReq *http.Request
// Cache FileInfoFaster results per path to avoid redundant calls within the same request
fileInfoCache map[string]*iteminfo.ExtendedFileInfo
}
@@ -626,6 +626,10 @@ func webDAVHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
httpReq: r,
}
if r.Method == http.MethodOptions {
return writeWebDAVOptions(w, r, filteredFS, prefix, filePerms)
}
wd := &webdav.Handler{
Prefix: prefix,
FileSystem: filteredFS,
@@ -657,6 +661,46 @@ func webDAVHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
return 200, nil // errors and responses (XML-formatted) are handled by webdav handler
}
// writeWebDAVOptions answers OPTIONS directly so the Allow/DAV headers reflect
// the methods this server actually supports and the user's permissions, instead
// of the webdav library's hardcoded advertisement (which includes LOCK etc.).
func writeWebDAVOptions(w http.ResponseWriter, r *http.Request, ffs *filteredFileSystem, prefix string, perms users.SourceFilePermissions) (int, error) {
rest, ok := strings.CutPrefix(r.URL.Path, prefix)
if !ok || (rest != "" && rest[0] != '/') {
return http.StatusNotFound, fmt.Errorf("request path %q outside webdav prefix %q", r.URL.Path, prefix)
}
requestPath := path.Clean("/" + rest)
var candidates []string
fi, err := ffs.Stat(r.Context(), requestPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
if errors.Is(err, os.ErrPermission) {
return http.StatusForbidden, err
}
return http.StatusInternalServerError, err
}
if err != nil {
candidates = []string{http.MethodPut, "MKCOL"}
} else if fi.IsDir() {
candidates = []string{"PROPFIND", http.MethodDelete, "COPY", "MOVE"}
} else {
candidates = []string{http.MethodGet, http.MethodHead, "PROPFIND", http.MethodPut, http.MethodDelete, "COPY", "MOVE"}
}
allowed := []string{http.MethodOptions}
for _, m := range candidates {
if _, err := webDAVMethodPermission(m, perms); err == nil {
allowed = append(allowed, m)
}
}
w.Header().Set("Allow", strings.Join(allowed, ", "))
w.Header().Set("DAV", "1")
w.Header().Set("MS-Author-Via", "DAV")
w.WriteHeader(http.StatusOK)
return http.StatusOK, nil
}
func webDAVMethodPermission(method string, perms users.SourceFilePermissions) (int, error) {
switch method {
case "PROPFIND", http.MethodOptions:
+153 -1
View File
@@ -874,7 +874,6 @@ func TestWebDAV_CopyPreservesModTime(t *testing.T) {
}
}
// TestWebDAV_ListingsIncludeHidden verifies that dotfiles/dotdirs are listed
// over WebDAV even when the user's showHidden UI preference is false, and that
// hidden paths are writable (needed for two-way sync clients like rclone).
@@ -994,3 +993,156 @@ func initTestIndex(t *testing.T, name, path string) {
// For WebDAV tests, indices are already initialized in setupWebDAVTestEnv
t.Helper()
}
// Test that OPTIONS advertises only the methods actually supported and
// permitted for the user, rather than the webdav library's hardcoded list.
func TestWebDAV_OPTIONS(t *testing.T) {
source1Path, _ := setupWebDAVTestEnv(t)
fullAccessUser := &users.User{
ID: 1,
FrontendUser: users.FrontendUser{
Username: "optionsfull",
},
BackendScopes: []users.BackendScope{
{Path: source1Path, Scope: "/"},
},
BackendSourcePermissions: webDAVPermsForPaths(true, true, true, true, source1Path),
Version: users.SourcePermissionsMigrationVersion,
}
applyBackendSourcePerms(fullAccessUser, fullAccessUser.BackendSourcePermissions)
readOnlyUser := &users.User{
ID: 2,
FrontendUser: users.FrontendUser{
Username: "optionsreadonly",
},
BackendScopes: []users.BackendScope{
{Path: source1Path, Scope: "/"},
},
BackendSourcePermissions: webDAVPermsForPaths(false, false, false, false, source1Path),
Version: users.SourcePermissionsMigrationVersion,
}
applyBackendSourcePerms(readOnlyUser, readOnlyUser.BackendSourcePermissions)
initTestIndex(t, "source1", source1Path)
// Methods the handler can never allow (they hit the default: -> 403 branch
// of webDAVMethodPermission) must never be advertised.
neverAllowed := []string{"LOCK", "UNLOCK", "PROPPATCH", "POST"}
doOptions := func(t *testing.T, user *users.User, path string) map[string]bool {
t.Helper()
req := httptest.NewRequest(http.MethodOptions, "/dav/source1"+path, nil)
req.SetPathValue("source", "source1")
req.SetPathValue("path", path)
w := httptest.NewRecorder()
status, err := webDAVHandler(w, req, &requestContext{User: user})
if err != nil {
t.Fatalf("webDAVHandler OPTIONS %s: %v", path, err)
}
if w.Code != http.StatusOK {
t.Fatalf("OPTIONS %s: expected status 200, got %d (status=%d)", path, w.Code, status)
}
if dav := w.Header().Get("DAV"); dav != "1" {
t.Errorf("OPTIONS %s: expected DAV \"1\", got %q", path, dav)
}
if via := w.Header().Get("MS-Author-Via"); via != "DAV" {
t.Errorf("OPTIONS %s: expected MS-Author-Via \"DAV\", got %q", path, via)
}
allowed := make(map[string]bool)
for _, m := range strings.Split(w.Header().Get("Allow"), ", ") {
if m != "" {
allowed[m] = true
}
}
if !allowed[http.MethodOptions] {
t.Errorf("OPTIONS %s: Allow %q missing OPTIONS", path, w.Header().Get("Allow"))
}
for _, m := range neverAllowed {
if allowed[m] {
t.Errorf("OPTIONS %s: Allow %q advertises unsupported method %s", path, w.Header().Get("Allow"), m)
}
}
// Consistency: everything advertised must pass webDAVMethodPermission
// for this user, so Allow never lies about what would succeed.
perms := user.BackendSourcePermissions[source1Path]
for m := range allowed {
if _, permErr := webDAVMethodPermission(m, perms); permErr != nil {
t.Errorf("OPTIONS %s: Allow advertises %s but webDAVMethodPermission denies it: %v", path, m, permErr)
}
}
return allowed
}
t.Run("directory advertises only supported methods", func(t *testing.T) {
allowed := doOptions(t, fullAccessUser, "/public/")
for _, m := range []string{"PROPFIND", http.MethodDelete, "COPY", "MOVE"} {
if !allowed[m] {
t.Errorf("expected %s in Allow for directory, got %v", m, allowed)
}
}
// GET/PUT/MKCOL don't apply to a collection URL itself
for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPut, "MKCOL"} {
if allowed[m] {
t.Errorf("unexpected %s in Allow for directory, got %v", m, allowed)
}
}
})
t.Run("file advertises read and write methods", func(t *testing.T) {
allowed := doOptions(t, fullAccessUser, "/public/readme.txt")
for _, m := range []string{http.MethodGet, http.MethodHead, "PROPFIND", http.MethodPut, http.MethodDelete, "COPY", "MOVE"} {
if !allowed[m] {
t.Errorf("expected %s in Allow for file, got %v", m, allowed)
}
}
})
t.Run("missing path advertises creation methods", func(t *testing.T) {
allowed := doOptions(t, fullAccessUser, "/public/newfile.txt")
for _, m := range []string{http.MethodPut, "MKCOL"} {
if !allowed[m] {
t.Errorf("expected %s in Allow for missing path, got %v", m, allowed)
}
}
})
t.Run("restricted path does not advertise creation methods", func(t *testing.T) {
path := "/public/not-viewable.txt"
req := httptest.NewRequest(http.MethodOptions, "/dav/source1"+path, nil)
req.SetPathValue("source", "source1")
req.SetPathValue("path", path)
w := httptest.NewRecorder()
status, err := webDAVHandler(w, req, &requestContext{User: fullAccessUser})
if err == nil {
t.Fatalf("expected error for restricted path, got status %d with Allow %q", status, w.Header().Get("Allow"))
}
if allow := w.Header().Get("Allow"); strings.Contains(allow, http.MethodPut) || strings.Contains(allow, "MKCOL") {
t.Errorf("restricted path advertises creation methods: %q", allow)
}
})
t.Run("read-only user is not told writes are allowed", func(t *testing.T) {
allowed := doOptions(t, readOnlyUser, "/public/readme.txt")
for _, m := range []string{http.MethodPut, http.MethodDelete, "MOVE", "COPY", "MKCOL"} {
if allowed[m] {
t.Errorf("read-only user unexpectedly sees %s in Allow: %v", m, allowed)
}
}
if !allowed["PROPFIND"] {
t.Errorf("expected PROPFIND in Allow for read-only user, got %v", allowed)
}
})
t.Run("read-only user sees no read methods without download", func(t *testing.T) {
allowed := doOptions(t, readOnlyUser, "/public/readme.txt")
for _, m := range []string{http.MethodGet, http.MethodHead} {
if allowed[m] {
t.Errorf("read-only (no download) user unexpectedly sees %s in Allow: %v", m, allowed)
}
}
})
}
@@ -0,0 +1,22 @@
//go:build darwin
package indexing
import (
"os"
"syscall"
"time"
)
// getCreatedTime returns the file's birth time from the stat result, or nil if unavailable.
func getCreatedTime(info os.FileInfo, _ string) *time.Time {
if info == nil {
return nil
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Birthtimespec.Sec <= 0 {
return nil
}
t := time.Unix(stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec)
return &t
}
@@ -0,0 +1,30 @@
//go:build linux
package indexing
import (
"os"
"time"
"golang.org/x/sys/unix"
)
// getCreatedTime returns the file's birth time via statx, or nil if the path is empty
// or the filesystem does not report one. The link itself is queried only when info
// describes a symlink; otherwise the target is followed, matching how info was obtained.
func getCreatedTime(info os.FileInfo, realPath string) *time.Time {
if realPath == "" {
return nil
}
flags := 0
if info != nil && info.Mode()&os.ModeSymlink != 0 {
flags = unix.AT_SYMLINK_NOFOLLOW
}
var stx unix.Statx_t
err := unix.Statx(unix.AT_FDCWD, realPath, flags, unix.STATX_BTIME, &stx)
if err != nil || stx.Mask&unix.STATX_BTIME == 0 || stx.Btime.Sec <= 0 {
return nil
}
t := time.Unix(stx.Btime.Sec, int64(stx.Btime.Nsec))
return &t
}
@@ -0,0 +1,56 @@
//go:build linux
package indexing
import (
"os"
"path/filepath"
"testing"
"time"
)
// TestGetCreatedTime_Symlink checks that the reported birth time follows the supplied
// FileInfo: the link's own time for Lstat info, the target's time for Stat info.
func TestGetCreatedTime_Symlink(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.txt")
link := filepath.Join(dir, "link")
if err := os.WriteFile(target, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
// Ensure the link's birth time differs from the target's.
time.Sleep(50 * time.Millisecond)
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
targetInfo, err := os.Stat(target)
if err != nil {
t.Fatal(err)
}
linkInfo, err := os.Lstat(link)
if err != nil {
t.Fatal(err)
}
followedInfo, err := os.Stat(link)
if err != nil {
t.Fatal(err)
}
targetCreated := getCreatedTime(targetInfo, target)
linkCreated := getCreatedTime(linkInfo, link)
if targetCreated == nil || linkCreated == nil {
t.Skip("filesystem does not report birth time")
}
if !linkCreated.After(*targetCreated) {
t.Skipf("filesystem birth times are too coarse to distinguish link (%v) from target (%v)", linkCreated, targetCreated)
}
if got := getCreatedTime(linkInfo, link); got == nil || !got.Equal(*linkCreated) {
t.Errorf("Lstat info: got %v, want link birth time %v", got, linkCreated)
}
if got := getCreatedTime(followedInfo, link); got == nil || !got.Equal(*targetCreated) {
t.Errorf("Stat info: got %v, want target birth time %v", got, targetCreated)
}
}
@@ -0,0 +1,13 @@
//go:build !darwin && !linux && !windows
package indexing
import (
"os"
"time"
)
// getCreatedTime always returns nil: birth time is not supported on this platform.
func getCreatedTime(_ os.FileInfo, _ string) *time.Time {
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package indexing
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
// TestGetCreatedTime checks that a freshly created file reports a recent birth time.
func TestGetCreatedTime(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "created_time_test.txt")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
created := getCreatedTime(info, path)
switch runtime.GOOS {
case "darwin", "windows":
case "linux":
if created == nil {
t.Skip("filesystem does not report birth time")
}
default:
if created != nil {
t.Errorf("expected nil created time on %s, got %v", runtime.GOOS, created)
}
return
}
if created == nil {
t.Fatal("expected non-nil created time")
}
if diff := time.Since(*created); diff < -time.Minute || diff > time.Minute {
t.Errorf("created time %v is not within one minute of now", created)
}
}
// TestGetCreatedTime_NilInfo checks that a nil FileInfo yields no creation time.
func TestGetCreatedTime_NilInfo(t *testing.T) {
if got := getCreatedTime(nil, ""); got != nil {
t.Errorf("expected nil for nil info, got %v", got)
}
}
@@ -0,0 +1,26 @@
//go:build windows
package indexing
import (
"os"
"syscall"
"time"
)
// getCreatedTime returns the file's creation time from its Win32 attribute data, or nil if unavailable.
func getCreatedTime(info os.FileInfo, _ string) *time.Time {
if info == nil {
return nil
}
stat, ok := info.Sys().(*syscall.Win32FileAttributeData)
if !ok {
return nil
}
nsec := stat.CreationTime.Nanoseconds()
if nsec <= 0 {
return nil
}
t := time.Unix(0, nsec)
return &t
}
+25 -6
View File
@@ -9,12 +9,13 @@ import (
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/dbindex"
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/net/webdav"
@@ -89,6 +90,10 @@ type Stats struct {
UsedAsIndexed uint64 `json:"used"`
UsedDisk uint64 `json:"usedAlt"`
DiskTotal uint64 `json:"total"`
// UsedDiskRoot/DiskTotalRoot are the root-filesystem-only view: a single
// statfs of the source path without aggregating nested mounts.
UsedDiskRoot uint64 `json:"usedAltRoot"`
DiskTotalRoot uint64 `json:"totalRoot"`
// UsageScopeMismatch is true when indexed used exceeds partition total at the
// source root (e.g. nested mounts under the source path). Display-only signal.
UsageScopeMismatch bool `json:"usageScopeMismatch,omitempty"`
@@ -601,6 +606,7 @@ func (idx *Index) getFileInfoFromContext(ctx *PathContext, isIndexable bool) (*i
ItemInfo: iteminfo.ItemInfo{
Name: ctx.BaseName,
Size: size,
Created: getCreatedTime(ctx.FileInfo, ctx.RealPath),
ModTime: ctx.FileInfo.ModTime(),
Hidden: ctx.IsHidden,
},
@@ -642,6 +648,7 @@ func (idx *Index) getBasicDirInfo(ctx *PathContext) *iteminfo.FileInfo {
Name: ctx.BaseName,
Type: "directory",
Size: 0,
Created: getCreatedTime(ctx.FileInfo, ctx.RealPath),
ModTime: ctx.FileInfo.ModTime(),
Hidden: ctx.IsHidden,
},
@@ -738,6 +745,7 @@ func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileI
ItemInfo: iteminfo.ItemInfo{
Name: baseName,
Size: int64(realSize),
Created: getCreatedTime(dirInfo, realPath),
ModTime: dirInfo.ModTime(),
},
}
@@ -833,6 +841,7 @@ func (idx *Index) processDirectoryItem(file os.FileInfo, indexPath string, subdi
itemInfo := &iteminfo.ItemInfo{
Name: file.Name(),
Created: getCreatedTime(file, utils.JoinPathAsUnix(idx.Path, indexPath, file.Name())),
ModTime: file.ModTime(),
Hidden: IsHidden(utils.JoinPathAsUnix(idx.Path, indexPath, file.Name())),
Type: "directory",
@@ -874,6 +883,7 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
fullCombined := utils.JoinPathAsUnix(idx.Path, indexPath, file.Name())
itemInfo := &iteminfo.ItemInfo{
Name: file.Name(),
Created: getCreatedTime(file, fullCombined),
ModTime: file.ModTime(),
Hidden: IsHidden(fullCombined),
}
@@ -1034,6 +1044,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
Name: baseName,
Type: "directory",
Size: totalSize,
Created: getCreatedTime(stat, dirInfo.Name()),
ModTime: stat.ModTime(),
HasPreview: hasPreview,
}
@@ -1564,15 +1575,17 @@ func (idx *Index) IsNeverWatchPath(adjustedPath string) bool {
return exists
}
func (idx *Index) SetUsage(totalDiskSize, partitionUsed, UsedAsIndexed uint64) {
func (idx *Index) SetUsage(aggregate, rootOnly fileutils.PartitionUsage, UsedAsIndexed uint64) {
if settings.Config.Frontend.DisableUsedPercentage {
return
}
idx.mu.Lock()
defer idx.mu.Unlock()
idx.DiskTotal = totalDiskSize
idx.DiskTotal = aggregate.Total
idx.UsedAsIndexed = UsedAsIndexed
idx.UsedDisk = partitionUsed
idx.UsedDisk = aggregate.Used
idx.DiskTotalRoot = rootOnly.Total
idx.UsedDiskRoot = rootOnly.Used
}
func (idx *Index) SetStatus(status IndexStatus) error {
@@ -1671,6 +1684,8 @@ func (idx *Index) writePersistedIndexInfo() error {
usedAsIndexed := idx.UsedAsIndexed
usedDisk := idx.UsedDisk
diskTotal := idx.DiskTotal
usedDiskRoot := idx.UsedDiskRoot
diskTotalRoot := idx.DiskTotalRoot
idx.mu.RUnlock()
info := &dbindex.IndexInfo{
@@ -1682,6 +1697,8 @@ func (idx *Index) writePersistedIndexInfo() error {
UsedAsIndexed: usedAsIndexed,
UsedDisk: usedDisk,
DiskTotal: diskTotal,
UsedDiskRoot: usedDiskRoot,
DiskTotalRoot: diskTotalRoot,
Scanners: scanners,
}
@@ -1715,6 +1732,8 @@ func (idx *Index) Load() error {
idx.UsedAsIndexed = info.UsedAsIndexed
idx.UsedDisk = info.UsedDisk
idx.DiskTotal = info.DiskTotal
idx.UsedDiskRoot = info.UsedDiskRoot
idx.DiskTotalRoot = info.DiskTotalRoot
// Restore scanner information (will be applied when scanners are created)
// Store in a temporary map that setupMultiScanner can use
+1
View File
@@ -660,6 +660,7 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
ItemInfo: iteminfo.ItemInfo{
Name: filepath.Base(strings.TrimSuffix(folderPath, "/")),
Size: sizeForDB,
Created: getCreatedTime(info, realPath),
ModTime: info.ModTime(),
Type: "directory",
Hidden: hidden,
+8 -7
View File
@@ -7,13 +7,14 @@ import (
)
type ItemInfo struct {
Name string `json:"name"` // name of the file
Size int64 `json:"size"` // length in bytes for regular files
ModTime time.Time `json:"modified"` // modification time
Type string `json:"type"` // type of the file, either "directory" or a file mimetype
Hidden bool `json:"hidden"` // whether the file is hidden
HasPreview bool `json:"hasPreview"` // whether the file has a thumbnail preview
IsShared bool `json:"isShared,omitempty"` // whether the file or folder is shared
Name string `json:"name"` // name of the file
Size int64 `json:"size"` // length in bytes for regular files
Created *time.Time `json:"created,omitempty"` // filesystem birth time, nil when unavailable
ModTime time.Time `json:"modified"` // modification time
Type string `json:"type"` // type of the file, either "directory" or a file mimetype
Hidden bool `json:"hidden"` // whether the file is hidden
HasPreview bool `json:"hasPreview"` // whether the file has a thumbnail preview
IsShared bool `json:"isShared,omitempty"` // whether the file or folder is shared
}
// ExtendedItemInfo extends ItemInfo with optional metadata that's only populated on-demand
+4 -2
View File
@@ -276,7 +276,7 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
_, ok = utils.DiskUsageCache.Get(cacheKey)
if !ok {
// Only fetch disk total and used if not cached (this is expensive, so we cache it)
totalPartitionSize, partitionUsed, err := fileutils.GetAggregatedPartitionUsage(sourcePath)
aggregate, rootOnly, err := fileutils.GetPartitionUsageVariants(sourcePath)
if err != nil {
logger.Errorf("Failed to get partition usage for index %s: %v", sourceName, err)
}
@@ -298,7 +298,7 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
indexedSizeFromDB += uint64(item.Size)
}
}
idx.SetUsage(totalPartitionSize, partitionUsed, indexedSizeFromDB)
idx.SetUsage(aggregate, rootOnly, indexedSizeFromDB)
utils.DiskUsageCache.Set(cacheKey, true)
if metaStoreConfigured() {
if err := idx.writePersistedIndexInfo(); err != nil {
@@ -333,6 +333,8 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
reducedIdx.UsedAsIndexed = idx.UsedAsIndexed
reducedIdx.UsedDisk = idx.UsedDisk
reducedIdx.DiskTotal = idx.DiskTotal
reducedIdx.UsedDiskRoot = idx.UsedDiskRoot
reducedIdx.DiskTotalRoot = idx.DiskTotalRoot
reducedIdx.UsageScopeMismatch = idx.DiskTotal > 0 && idx.UsedAsIndexed > idx.DiskTotal
reducedIdx.Scanners = scannerInfos
reducedIdx.NumDirs = idx.getNumDirsUnlocked()
+16 -12
View File
@@ -24,6 +24,7 @@ type SearchResult struct {
Type string `json:"type"`
Size int64 `json:"size"`
Modified string `json:"modified,omitempty"`
Created string `json:"created,omitempty"`
HasPreview bool `json:"hasPreview"`
Source string `json:"source"`
}
@@ -78,12 +79,13 @@ func (idx *Index) SearchParsed(baseOpts iteminfo.SearchOptions, scope string, so
var path string
var name string
var size int64
var createdTime int64
var modTime int64
var mimeType string
var isDir bool
var hasPreview bool
if err := rows.Scan(&path, &name, &size, &modTime, &mimeType, &isDir, &hasPreview); err != nil {
if err := rows.Scan(&path, &name, &size, &createdTime, &modTime, &mimeType, &isDir, &hasPreview); err != nil {
logger.Errorf("Failed to scan search result row: %v", err)
continue
}
@@ -112,10 +114,17 @@ func (idx *Index) SearchParsed(baseOpts iteminfo.SearchOptions, scope string, so
HasPreview: hasPreview,
Source: idx.Name,
}
if createdTime > 0 {
results[path].Created = time.Unix(createdTime, 0).Format(time.RFC3339)
}
count++
}
}
return sortSearchResults(results)
}
func sortSearchResults(results map[string]*SearchResult) []*SearchResult {
sortedKeys := make([]*SearchResult, 0, len(results))
for _, v := range results {
sortedKeys = append(sortedKeys, v)
@@ -242,12 +251,13 @@ func SearchMultiSourcesParsed(baseOpts iteminfo.SearchOptions, sources []string,
var path string
var name string
var size int64
var createdTime int64
var modTime int64
var mimeType string
var isDir bool
var hasPreview bool
if err := rows.Scan(&source, &path, &name, &size, &modTime, &mimeType, &isDir, &hasPreview); err != nil {
if err := rows.Scan(&source, &path, &name, &size, &createdTime, &modTime, &mimeType, &isDir, &hasPreview); err != nil {
logger.Errorf("Failed to scan search result row: %v", err)
continue
}
@@ -277,18 +287,12 @@ func SearchMultiSourcesParsed(baseOpts iteminfo.SearchOptions, sources []string,
HasPreview: hasPreview,
Source: source,
}
if createdTime > 0 {
results[key].Created = time.Unix(createdTime, 0).Format(time.RFC3339)
}
count++
}
}
sortedKeys := make([]*SearchResult, 0, len(results))
for _, v := range results {
sortedKeys = append(sortedKeys, v)
}
sort.Slice(sortedKeys, func(i, j int) bool {
parts1 := strings.Split(sortedKeys[i].Path, "/")
parts2 := strings.Split(sortedKeys[j].Path, "/")
return len(parts1) < len(parts2)
})
return sortedKeys
return sortSearchResults(results)
}
@@ -35,6 +35,8 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
SingleClick: true,
HideFileExt: "",
ShowCopyPath: false,
ShowTypeColumn: true,
ShowCreationDateColumn: true,
DeleteAfterArchive: false,
ViewMode: "list",
GallerySize: 7,
@@ -52,7 +54,6 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
Models: boolPtr(false),
},
FileViewer: UserDefaultsFileViewer{
EditorQuickSave: true,
AutoplayMedia: boolPtr(false),
DisableViewingExt: ".bat",
DisableOnlyOfficeExt: ".x",
@@ -107,7 +108,6 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
Realtime: true,
},
NonAdminEditable: users.NonAdminEditable{
EditorQuickSave: true,
HideSidebarFileActions: true,
DisableQuickToggles: true,
DisableSearchOptions: true,
@@ -129,6 +129,8 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
DisableOnlyOfficeExt: ".x",
CustomTheme: "Night",
ShowSelectMultiple: true,
ShowTypeColumn: true,
ShowCreationDateColumn: true,
DebugOffice: true,
DeleteWithoutConfirming: true,
DeleteAfterArchive: false,
+18 -4
View File
@@ -25,6 +25,11 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
const (
defaultMinSearchLength = 3
defaultFrontendDescription = "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server"
)
var Config Settings
const (
@@ -279,7 +284,7 @@ func setupFrontend(generate bool) {
// Load login icon configuration at startup
loadLoginIcon()
if Config.Server.MinSearchLength == 0 {
Config.Server.MinSearchLength = 3
Config.Server.MinSearchLength = defaultMinSearchLength
}
if !Config.Frontend.DisableDefaultLinks {
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
@@ -293,7 +298,7 @@ func setupFrontend(generate bool) {
})
}
if Config.Frontend.Description == "" {
Config.Frontend.Description = "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server"
Config.Frontend.Description = defaultFrontendDescription
}
Config.Frontend.Styling.LightBackground = FallbackColor(Config.Frontend.Styling.LightBackground, "#f5f5f5")
Config.Frontend.Styling.DarkBackground = FallbackColor(Config.Frontend.Styling.DarkBackground, "#141D24")
@@ -654,6 +659,10 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
return fmt.Errorf("error parsing YAML data: %v", err)
}
// Expand $VAR / ${VAR} in decoded string scalars only (after parse) so
// secrets may contain quotes, backslashes, or newlines safely.
expandConfigEnv(rawConfig)
// Filter to only keep valid top-level Settings struct fields
// This removes anchor definitions that are just templates (e.g., "test_server: &test_server")
validFields := map[string]bool{
@@ -863,6 +872,8 @@ func SetDefaults(generate bool) Settings {
BaseURL: "",
},
Server: Server{
SearchResultsLimit: 1000,
MinSearchLength: defaultMinSearchLength,
NumImageProcessors: 4,
DatabaseV2: Database{
Path: databaseV2,
@@ -903,7 +914,8 @@ func SetDefaults(generate bool) Settings {
},
},
Frontend: Frontend{
Name: "FileBrowser Quantum",
Name: "FileBrowser Quantum",
Description: defaultFrontendDescription,
},
UserDefaults: UserDefaults{
// New organized structure
@@ -925,6 +937,8 @@ func SetDefaults(generate bool) Settings {
SingleClick: false,
HideFileExt: "",
ShowCopyPath: false,
ShowTypeColumn: false,
ShowCreationDateColumn: false,
DeleteAfterArchive: true,
ViewMode: "normal",
GallerySize: 3,
@@ -943,7 +957,6 @@ func SetDefaults(generate bool) Settings {
Models: boolPtr(true),
},
FileViewer: UserDefaultsFileViewer{
EditorQuickSave: false,
AutoplayMedia: boolPtr(true),
DisableViewingExt: "",
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
@@ -972,6 +985,7 @@ func SetDefaults(generate bool) Settings {
Realtime: false,
},
LockPassword: false,
RequirePasswordChange: false,
DisableSettings: false,
LoginMethod: "",
DisableUpdateNotifications: false,
+106
View File
@@ -0,0 +1,106 @@
package settings
import (
"os"
"strconv"
"strings"
)
// expandConfigEnv walks a decoded YAML value tree and substitutes $VAR / ${VAR}
// in string scalars using the process environment. Non-string scalars (numbers,
// booleans, null) are left unchanged so typed config fields stay intact.
// Expansion happens after YAML decode so secrets may contain quotes, backslashes,
// or newlines without breaking the document.
// Maps and slices are mutated in place.
func expandConfigEnv(v interface{}) {
switch x := v.(type) {
case map[string]interface{}:
for k, val := range x {
x[k] = expandConfigEnvValue(val)
}
case []interface{}:
for i, val := range x {
x[i] = expandConfigEnvValue(val)
}
}
}
func expandConfigEnvValue(v interface{}) interface{} {
switch x := v.(type) {
case map[string]interface{}:
expandConfigEnv(x)
return x
case []interface{}:
expandConfigEnv(x)
return x
case string:
return expandConfigEnvString(x)
default:
// int, float, bool, nil, etc.
return v
}
}
// expandConfigEnvString expands environment references in a YAML string scalar.
// When the entire scalar is a single ${VAR} or $VAR reference, the expanded
// value is coerced to bool/int/float when unambiguous so config fields of those
// types keep working (e.g. port: ${PORT}). Partial expansions and ordinary
// strings always remain strings so secrets like "true" or "123" stay safe.
func expandConfigEnvString(s string) interface{} {
if !strings.ContainsAny(s, "$") {
return s
}
expanded := os.ExpandEnv(s)
if isSingleEnvReference(s) {
if coerced, ok := coerceEnvScalar(expanded); ok {
return coerced
}
}
return expanded
}
func isSingleEnvReference(s string) bool {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "${") && strings.HasSuffix(s, "}") {
inner := s[2 : len(s)-1]
return isEnvName(inner)
}
if strings.HasPrefix(s, "$") && !strings.HasPrefix(s, "${") {
return isEnvName(s[1:])
}
return false
}
func isEnvName(name string) bool {
if name == "" {
return false
}
for i, r := range name {
if i == 0 {
if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && r != '_' {
return false
}
continue
}
if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' {
return false
}
}
return true
}
func coerceEnvScalar(s string) (interface{}, bool) {
switch s {
case "true", "True", "TRUE":
return true, true
case "false", "False", "FALSE":
return false, true
}
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
return i, true
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f, true
}
return nil, false
}
+184
View File
@@ -0,0 +1,184 @@
package settings
import (
"os"
"path/filepath"
"testing"
)
func TestExpandConfigEnvStrings(t *testing.T) {
t.Setenv("TEST_EXPAND_ENV_VAR", "from-env")
in := map[string]interface{}{
"password": "${TEST_EXPAND_ENV_VAR}",
"plain": "no-expansion",
"nested": map[string]interface{}{
"value": "$TEST_EXPAND_ENV_VAR",
},
"list": []interface{}{"${TEST_EXPAND_ENV_VAR}", "keep"},
"port": 8080,
"on": true,
}
expandConfigEnv(in)
if in["password"] != "from-env" {
t.Fatalf("password = %#v, want from-env", in["password"])
}
if in["plain"] != "no-expansion" {
t.Fatalf("plain = %#v", in["plain"])
}
nested, ok := in["nested"].(map[string]interface{})
if !ok {
t.Fatalf("nested = %#v, want map", in["nested"])
}
if nested["value"] != "from-env" {
t.Fatalf("nested.value = %#v", nested["value"])
}
list, ok := in["list"].([]interface{})
if !ok {
t.Fatalf("list = %#v, want slice", in["list"])
}
if list[0] != "from-env" || list[1] != "keep" {
t.Fatalf("list = %#v", list)
}
if in["port"] != 8080 {
t.Fatalf("port = %#v, want typed int 8080", in["port"])
}
if in["on"] != true {
t.Fatalf("on = %#v, want typed bool true", in["on"])
}
}
func TestExpandConfigEnvCoercesWholeReference(t *testing.T) {
t.Setenv("TEST_ENV_PORT", "9090")
t.Setenv("TEST_ENV_FLAG", "true")
t.Setenv("TEST_ENV_FLOAT", "1.5")
in := map[string]interface{}{
"port": "${TEST_ENV_PORT}",
"flag": "${TEST_ENV_FLAG}",
"rate": "${TEST_ENV_FLOAT}",
// Partial expansion must stay a string (and not coerce "true"-like secrets).
"msg": "prefix-${TEST_ENV_FLAG}",
}
expandConfigEnv(in)
if in["port"] != int64(9090) {
t.Fatalf("port = %#v (%T), want int64 9090", in["port"], in["port"])
}
if in["flag"] != true {
t.Fatalf("flag = %#v, want bool true", in["flag"])
}
if in["rate"] != 1.5 {
t.Fatalf("rate = %#v, want float64 1.5", in["rate"])
}
if in["msg"] != "prefix-true" {
t.Fatalf("msg = %#v, want string prefix-true", in["msg"])
}
}
func TestConfigLoadYamlEnvSubstitution(t *testing.T) {
testDir := t.TempDir()
const secret = "ldap-bind-secret"
t.Setenv("FILEBROWSER_LDAP_USER_PASSWORD", secret)
configContent := []byte(`
auth:
methods:
ldap:
enabled: true
server: "ldap://ldap.example.com:389"
baseDN: "dc=example,dc=com"
userDN: "cn=admin,dc=example,dc=com"
userPassword: "${FILEBROWSER_LDAP_USER_PASSWORD}"
server:
sources:
- path: "."
`)
configFile := filepath.Join(testDir, "config.yaml")
if err := os.WriteFile(configFile, configContent, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
if err := loadConfigWithDefaults(configFile, true); err != nil {
t.Fatalf("loadConfigWithDefaults: %v", err)
}
if Config.Auth.Methods.LdapAuth.UserPassword != secret {
t.Fatalf("userPassword = %q, want %q", Config.Auth.Methods.LdapAuth.UserPassword, secret)
}
}
func TestConfigLoadYamlEnvSpecialCharacters(t *testing.T) {
tests := []struct {
name string
secret string
}{
{name: "quotes", secret: `p@ss"word'with"quotes`},
{name: "backslashes", secret: `path\to\secret\and\\more`},
{name: "multiline", secret: "line1\nline2\nline3"},
{name: "yaml_special", secret: `a: b # not yaml : [true]`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testDir := t.TempDir()
t.Setenv("FILEBROWSER_LDAP_USER_PASSWORD", tt.secret)
configContent := []byte(`
auth:
methods:
ldap:
enabled: true
server: "ldap://ldap.example.com:389"
baseDN: "dc=example,dc=com"
userDN: "cn=admin,dc=example,dc=com"
userPassword: "${FILEBROWSER_LDAP_USER_PASSWORD}"
server:
sources:
- path: "."
`)
configFile := filepath.Join(testDir, "config.yaml")
if err := os.WriteFile(configFile, configContent, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
if err := loadConfigWithDefaults(configFile, true); err != nil {
t.Fatalf("loadConfigWithDefaults: %v", err)
}
if Config.Auth.Methods.LdapAuth.UserPassword != tt.secret {
t.Fatalf("userPassword = %q, want %q", Config.Auth.Methods.LdapAuth.UserPassword, tt.secret)
}
})
}
}
func TestConfigLoadYamlEnvPreservesTypedScalars(t *testing.T) {
testDir := t.TempDir()
t.Setenv("TEST_HTTP_PORT", "8443")
t.Setenv("TEST_TRUST_PROXY", "true")
configContent := []byte(`
http:
port: ${TEST_HTTP_PORT}
trustProxyHeaders: ${TEST_TRUST_PROXY}
auth:
methods:
password:
enabled: true
server:
sources:
- path: "."
`)
configFile := filepath.Join(testDir, "config.yaml")
if err := os.WriteFile(configFile, configContent, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
if err := loadConfigWithDefaults(configFile, true); err != nil {
t.Fatalf("loadConfigWithDefaults: %v", err)
}
if Config.Http.Port != 8443 {
t.Fatalf("http.port = %d, want 8443", Config.Http.Port)
}
if !Config.Http.TrustProxyHeaders {
t.Fatal("expected http.trustProxyHeaders to be true")
}
}
+40
View File
@@ -139,3 +139,43 @@ func TestInvalidConfig(t *testing.T) {
}
}
}
func TestSearchResultsLimitConfig(t *testing.T) {
original := Config
t.Cleanup(func() { Config = original })
for _, tc := range []struct {
name string
value string
want int
invalid bool
}{
{name: "default", want: 1000},
{name: "custom", value: "searchResultsLimit: 250", want: 250},
{name: "zero", value: "searchResultsLimit: 0", invalid: true},
{name: "negative", value: "searchResultsLimit: -1", invalid: true},
} {
t.Run(tc.name, func(t *testing.T) {
configFile := filepath.Join(t.TempDir(), "config.yaml")
content := "server:\n sources:\n - path: .\n " + tc.value + "\n"
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatal(err)
}
if err := loadConfigWithDefaults(configFile, true); err != nil {
t.Fatal(err)
}
err := ValidateConfig(Config)
if tc.invalid {
if err == nil {
t.Fatal("expected invalid search result limit to be rejected")
}
return
}
if err != nil {
t.Fatal(err)
}
if Config.Server.SearchResultsLimit != tc.want {
t.Fatalf("want %d, got %d", tc.want, Config.Server.SearchResultsLimit)
}
})
}
}
+17 -1
View File
@@ -15,6 +15,22 @@ var AuthManagedEnforcementPaths = map[string]struct{}{
"account.permissions.admin": {},
}
// TransientAccountEnforcementPaths are enforced defaults that seed account state but must not
// block updates when the user satisfies a one-time requirement (e.g. clearing requirePasswordChange).
var TransientAccountEnforcementPaths = map[string]struct{}{
"account.requirePasswordChange": {},
}
func enforcementPathExcludedFromUserSync(path string) bool {
if _, ok := AuthManagedEnforcementPaths[path]; ok {
return true
}
if _, ok := TransientAccountEnforcementPaths[path]; ok {
return true
}
return false
}
// EnforcementAppliesToUser reports whether user-default enforcement should affect u.
// Admins are exempt; anonymous and nil users are not subject to enforcement.
func EnforcementAppliesToUser(u *users.User) bool {
@@ -30,7 +46,7 @@ func withoutAuthManagedEnforcementPaths(paths map[string]struct{}) map[string]st
}
out := make(map[string]struct{}, len(paths))
for path := range paths {
if _, authManaged := AuthManagedEnforcementPaths[path]; authManaged {
if enforcementPathExcludedFromUserSync(path) {
continue
}
out[path] = struct{}{}
@@ -83,6 +83,39 @@ func TestValidateUserAgainstEnforcedDefaults_skipsAdmin(t *testing.T) {
}
}
func TestValidateUserAgainstEnforcedDefaults_skipsRequirePasswordChange(t *testing.T) {
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "alice",
RequirePasswordChange: false,
},
}
defaults := UserDefaults{Account: UserDefaultsAccount{RequirePasswordChange: true}}
enforced := UserDefaultsEnforcement{
Account: UserDefaultsAccountEnforcement{RequirePasswordChange: true},
}
if err := ValidateUserAgainstEnforcedDefaults(u, defaults, enforced); err != nil {
t.Fatalf("cleared requirePasswordChange should not fail enforced validation: %v", err)
}
}
func TestApplyEnforcedDefaultsFrom_skipsRequirePasswordChange(t *testing.T) {
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "alice",
RequirePasswordChange: false,
},
}
defaults := UserDefaults{Account: UserDefaultsAccount{RequirePasswordChange: true}}
enforced := UserDefaultsEnforcement{
Account: UserDefaultsAccountEnforcement{RequirePasswordChange: true},
}
ApplyEnforcedDefaultsFrom(u, defaults, enforced)
if u.RequirePasswordChange {
t.Fatal("expected requirePasswordChange not re-applied after user cleared it")
}
}
func TestApplyEnforcedDefaultsFrom_onlyEnforcedSubset(t *testing.T) {
u := &users.User{
FrontendUser: users.FrontendUser{
+3 -1
View File
@@ -31,6 +31,8 @@ type UserDefaultsListingEnforcement struct {
SingleClick bool `json:"singleClick,omitempty"`
HideFileExt bool `json:"hideFileExt,omitempty"`
ShowCopyPath bool `json:"showCopyPath,omitempty"`
ShowTypeColumn bool `json:"showTypeColumn,omitempty"`
ShowCreationDateColumn bool `json:"showCreationDateColumn,omitempty"`
DeleteAfterArchive bool `json:"deleteAfterArchive,omitempty"`
ViewMode bool `json:"viewMode,omitempty"`
GallerySize bool `json:"gallerySize,omitempty"`
@@ -50,7 +52,6 @@ type UserDefaultsPreviewEnforcement struct {
}
type UserDefaultsFileViewerEnforcement struct {
EditorQuickSave bool `json:"editorQuickSave,omitempty"`
AutoplayMedia bool `json:"autoplayMedia,omitempty"`
DisableViewingExt bool `json:"disableViewingExt,omitempty"`
DisableOnlyOfficeExt bool `json:"disableOnlyOfficeExt,omitempty"`
@@ -77,6 +78,7 @@ type UserDefaultsFileLoadingEnforcement struct {
type UserDefaultsAccountEnforcement struct {
LockPassword bool `json:"lockPassword,omitempty"`
RequirePasswordChange bool `json:"requirePasswordChange,omitempty"`
DisableSettings bool `json:"disableSettings,omitempty"`
DisableUpdateNotifications bool `json:"disableUpdateNotifications,omitempty"`
ShowAdvancedProfile bool `json:"showAdvancedProfile,omitempty"`
Loaded 100 of 423 files, more files were not shown because too many files have changed in this diff. Show more