mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 13:12:00 -05:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91bc608f0e | ||
|
|
e21d553559 | ||
|
|
fde6857344 | ||
|
|
37aec9ebd8 | ||
|
|
4bf1ba312a | ||
|
+5 |
c8459eb404 | ||
|
|
241bbd69b4 | ||
|
|
9de21cd896 | ||
|
|
fcec78b57c | ||
|
|
12f27133b5 | ||
|
|
20a5d75d7e | ||
|
+5 |
8f2e8add9a | ||
|
|
6e98a9d4c8 | ||
|
|
c74817b1ab | ||
|
|
ed9b29f2e1 |
No files matched your search
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_REPO="${IMAGE_REPO:-gtstef/filebrowser}"
|
||||
SLIM_SUFFIX="${SLIM_SUFFIX:-}"
|
||||
|
||||
if [[ -z "${GITHUB_OUTPUT:-}" ]]; then
|
||||
echo "GITHUB_OUTPUT is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
write_outputs() {
|
||||
local update_track="$1"
|
||||
local update_major="$2"
|
||||
echo "update_track=${update_track}" >> "$GITHUB_OUTPUT"
|
||||
echo "update_major=${update_major}" >> "$GITHUB_OUTPUT"
|
||||
}
|
||||
|
||||
registry_version() {
|
||||
local tag="$1"
|
||||
local ver err err_file
|
||||
err_file="$(mktemp)"
|
||||
if ! ver="$(docker buildx imagetools inspect "${IMAGE_REPO}:${tag}" \
|
||||
--format '{{ with index .Image "linux/amd64" }}{{ index .Config.Labels "org.opencontainers.image.version" }}{{ end }}' 2>"$err_file")"; then
|
||||
err="$(cat "$err_file")"
|
||||
rm -f "$err_file"
|
||||
if grep -qiE 'not found|manifest unknown' <<<"$err"; then
|
||||
ver=""
|
||||
else
|
||||
echo "Failed to inspect ${IMAGE_REPO}:${tag}: ${err}" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
rm -f "$err_file"
|
||||
if [[ -z "$ver" ]]; then
|
||||
echo "Missing org.opencontainers.image.version on ${IMAGE_REPO}:${tag} (linux/amd64)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
ver="${ver#v}"
|
||||
printf '%s' "$ver"
|
||||
}
|
||||
|
||||
semver_allows_update() {
|
||||
local new="$1"
|
||||
local incumbent="$2"
|
||||
if [[ -z "$incumbent" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local winner
|
||||
winner="$(printf '%s\n' "$incumbent" "$new" | sort -V | tail -n1)"
|
||||
[[ "$winner" == "$new" ]]
|
||||
}
|
||||
|
||||
ref="${GITHUB_REF_NAME:-}"
|
||||
ref="${ref#v}"
|
||||
|
||||
if [[ ! "$ref" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then
|
||||
echo "Tag '${GITHUB_REF_NAME:-}' has no X.Y.Z-suffix form; leaving major-only floats enabled, track floats disabled"
|
||||
write_outputs false true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
new_version="$ref"
|
||||
semver_core="${BASH_REMATCH[1]}"
|
||||
suffix="${BASH_REMATCH[2]}"
|
||||
major="$(echo "$semver_core" | cut -d. -f1)"
|
||||
|
||||
track=""
|
||||
case "$suffix" in
|
||||
beta) track="beta" ;;
|
||||
stable) track="stable" ;;
|
||||
*)
|
||||
echo "Tag suffix '${suffix}' is not beta/stable; leaving major-only floats enabled, track floats disabled"
|
||||
write_outputs false true
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
track_tag="${track}${SLIM_SUFFIX}"
|
||||
major_tag="${major}-${track}${SLIM_SUFFIX}"
|
||||
|
||||
incumbent_track="$(registry_version "$track_tag")"
|
||||
incumbent_major="$(registry_version "$major_tag")"
|
||||
|
||||
update_track=false
|
||||
if semver_allows_update "$new_version" "$incumbent_track"; then
|
||||
update_track=true
|
||||
fi
|
||||
|
||||
update_major=false
|
||||
if semver_allows_update "$new_version" "$incumbent_major"; then
|
||||
update_major=true
|
||||
fi
|
||||
|
||||
echo "Floating tag policy (${SLIM_SUFFIX:-full} image):"
|
||||
echo " New version: ${new_version}"
|
||||
echo " Track tag: ${track_tag} (incumbent: ${incumbent_track:-<none>}) -> update_track=${update_track}"
|
||||
echo " Major tag: ${major_tag} (incumbent: ${incumbent_major:-<none>}) -> update_major=${update_major}"
|
||||
|
||||
write_outputs "$update_track" "$update_major"
|
||||
@@ -35,44 +35,3 @@ jobs:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
|
||||
create_beta_release:
|
||||
needs: [test_playwright]
|
||||
if: startsWith(github.event.head_commit.message, 'Beta/v')
|
||||
name: Create Beta Release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
fetch-depth: 0
|
||||
- name: Extract version from commit message
|
||||
id: extract_version
|
||||
shell: bash
|
||||
run: |
|
||||
tag_name=$(echo "${{ github.event.head_commit.message }}" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
|
||||
if [ -z "$tag_name" ] || [ "$tag_name" = "-beta" ]; then
|
||||
echo "Could not extract version from: ${{ github.event.head_commit.message }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
|
||||
- name: Tag release commit on main
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
TAG="${{ steps.extract_version.outputs.tag_name }}"
|
||||
git tag -f "$TAG" "${{ github.sha }}"
|
||||
git push origin "refs/tags/$TAG" --force
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_version.outputs.tag_name }}
|
||||
prerelease: false
|
||||
make_latest: false
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
overwrite_existing: true
|
||||
name: ${{ steps.extract_version.outputs.tag_name }}
|
||||
@@ -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
@@ -61,18 +61,23 @@ jobs:
|
||||
images: |
|
||||
gtstef/filebrowser
|
||||
ghcr.io/gtsteffaniak/filebrowser
|
||||
- name: Decide floating Docker tags
|
||||
id: floating
|
||||
run: bash .github/scripts/docker-floating-tag-policy.sh
|
||||
- name: Modify tags (strip 'v' and add version tags)
|
||||
id: modify-json
|
||||
run: |
|
||||
JSON='${{ steps.meta.outputs.json }}'
|
||||
MODIFIED_JSON=$(echo "$JSON" | jq '
|
||||
MODIFIED_JSON=$(echo "$JSON" | jq \
|
||||
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
|
||||
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
|
||||
.tags |= map(
|
||||
sub(":v"; ":") # Strip the 'v'
|
||||
| select(test(":latest") | not)
|
||||
) |
|
||||
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor and major tags
|
||||
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor (always) and major tags (when allowed)
|
||||
.tags += [
|
||||
.tags[]
|
||||
.tags[]
|
||||
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
|
||||
| split(":")
|
||||
| .[-1] as $version
|
||||
@@ -83,18 +88,20 @@ jobs:
|
||||
| ($version_parts[0]) as $major
|
||||
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
if $updateMajor then
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
else empty end
|
||||
] |
|
||||
# Add suffix-only tags for beta (only if tag ends with -beta)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-beta")) then
|
||||
# Add suffix-only tags for beta when semver policy allows
|
||||
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:beta",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta"
|
||||
]
|
||||
else . end |
|
||||
# Add suffix-only tags for stable (and latest) (only if tag ends with -stable)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-stable")) then
|
||||
# Add suffix-only tags for stable (and latest) when semver policy allows
|
||||
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:stable",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable",
|
||||
@@ -146,19 +153,26 @@ jobs:
|
||||
images: |
|
||||
gtstef/filebrowser
|
||||
ghcr.io/gtsteffaniak/filebrowser
|
||||
- name: Decide floating Docker tags
|
||||
id: floating
|
||||
env:
|
||||
SLIM_SUFFIX: -slim
|
||||
run: bash .github/scripts/docker-floating-tag-policy.sh
|
||||
- name: Modify tags (strip 'v' and add 'slim' if needed)
|
||||
id: modify-json-slim
|
||||
run: |
|
||||
JSON='${{ steps.meta.outputs.json }}'
|
||||
MODIFIED_JSON=$(echo "$JSON" | jq '
|
||||
MODIFIED_JSON=$(echo "$JSON" | jq \
|
||||
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
|
||||
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
|
||||
.tags |= map(
|
||||
sub(":v"; ":") # Strip the 'v'
|
||||
| select(test(":latest") | not) # Remove the "latest" tag
|
||||
| . + "-slim" # Append "-slim" to the versioned tag
|
||||
) |
|
||||
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor and major tags
|
||||
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor (always) and major tags (when allowed)
|
||||
.tags += [
|
||||
.tags[]
|
||||
.tags[]
|
||||
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
|
||||
| split(":")
|
||||
| .[-1] as $version
|
||||
@@ -169,18 +183,20 @@ jobs:
|
||||
| ($version_parts[0]) as $major
|
||||
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
if $updateMajor then
|
||||
"gtstef/filebrowser:\($major)-\($suffix)",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
|
||||
else empty end
|
||||
] |
|
||||
# Add suffix-only tags for beta-slim (only if tag ends with -beta-slim)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
|
||||
# Add suffix-only tags for beta-slim when semver policy allows
|
||||
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:beta-slim",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
|
||||
]
|
||||
else . end |
|
||||
# Add suffix-only tags for stable-slim (only if tag ends with -stable-slim)
|
||||
if any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
|
||||
# Add suffix-only tags for stable-slim when semver policy allows
|
||||
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
|
||||
.tags += [
|
||||
"gtstef/filebrowser:stable-slim",
|
||||
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
|
||||
|
||||
@@ -46,6 +46,7 @@ frontend/.tmp-icons-*
|
||||
/backend/internal/web/dist
|
||||
/backend/internal/web/embed
|
||||
/backend/debug
|
||||
/backend/http
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
|
||||
|
||||
:pushpin: [v2.0.0 is now in beta!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/)
|
||||
:pushpin: [v2.0.0 is now stable!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) --> See [migration support discussion](https://github.com/gtsteffaniak/filebrowser/discussions/3105)
|
||||
|
||||
## About
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM gtstef/ffmpeg:9.0.1-decode AS ffmpeg
|
||||
FROM gtstef/ffmpeg:9.0.2-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
|
||||
@@ -14,5 +14,9 @@ server:
|
||||
config:
|
||||
defaultEnabled: true
|
||||
userDefaults:
|
||||
listing:
|
||||
viewMode: "normal"
|
||||
# Size 4: switch-view "gallery" family resolves to icons (not gallery); list family → list.
|
||||
gallerySize: 3
|
||||
account:
|
||||
showAdvancedProfile: true
|
||||
@@ -12,7 +12,7 @@ import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
|
||||
*/
|
||||
export default defineConfig<PlaywrightFixtureOptions>({
|
||||
globalSetup: "./tests/playwright/screenshots-setup.ts",
|
||||
timeout: 5000,
|
||||
timeout: 60_000,
|
||||
testDir: "./tests/playwright/screenshots",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
|
||||
+38
-20
@@ -8,7 +8,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
@@ -225,19 +224,11 @@ func createConfig(configpath string, noInput bool) error {
|
||||
fmt.Printf("Error: '%s' is not a valid port. Please enter a number between 1 and 65535.\n", portStr)
|
||||
}
|
||||
|
||||
for {
|
||||
levels := askQuestion(reader, "What should the log levels be?", "info|warning|error")
|
||||
checkLevels := SplitByMultiple(levels)
|
||||
invalidOptions := []string{}
|
||||
for _, level := range checkLevels {
|
||||
if !(level == "info" || level == "warning" || level == "error" || level == "debug") {
|
||||
invalidOptions = append(invalidOptions, level)
|
||||
}
|
||||
}
|
||||
if len(invalidOptions) == 0 {
|
||||
break
|
||||
}
|
||||
fmt.Printf("Error: invalid options given '%s'. valid options: 'info|warning|error|debug'.\n", invalidOptions)
|
||||
config.Server.Logging = []settings.LogConfig{
|
||||
{
|
||||
Output: "stdout",
|
||||
Levels: "info|warning|error",
|
||||
},
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -250,9 +241,20 @@ func createConfig(configpath string, noInput bool) error {
|
||||
}
|
||||
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
|
||||
}
|
||||
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
|
||||
config.Auth.Methods.PasswordAuth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
|
||||
config.Auth.Methods.PasswordAuth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
|
||||
const defaultFrontendName = "FileBrowser Quantum"
|
||||
brandName := askQuestion(reader, "What should the application brand name be?", defaultFrontendName)
|
||||
if brandName != defaultFrontendName {
|
||||
config.Frontend.Name = brandName
|
||||
}
|
||||
adminUsername := askQuestion(reader, "What should the default admin username be?", "admin")
|
||||
if adminUsername != "admin" {
|
||||
config.Auth.Methods.PasswordAuth.AdminUsername = adminUsername
|
||||
}
|
||||
const adminPasswordGenerateDefault = "generate on startup"
|
||||
adminPassword := askQuestion(reader, "What should the default admin password be?", adminPasswordGenerateDefault)
|
||||
if adminPassword != "" && !strings.EqualFold(adminPassword, adminPasswordGenerateDefault) {
|
||||
config.Auth.Methods.PasswordAuth.AdminPassword = adminPassword
|
||||
}
|
||||
|
||||
modifyDefault := askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
|
||||
config.Server.Sources[0].Config.DefaultPermissions = settings.NormalizeSourceFilePermissions(users.SourceFilePermissions{
|
||||
@@ -266,12 +268,12 @@ func createConfig(configpath string, noInput bool) error {
|
||||
|
||||
fmt.Println("--- Configuration Complete ---")
|
||||
|
||||
yamlData, err := yaml.Marshal(&config)
|
||||
yamlData, err := generateSetupConfigYAML(&config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
return fmt.Errorf("generating config YAML: %w", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(configpath, yamlData, 0600)
|
||||
err = os.WriteFile(configpath, []byte(yamlData), 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing config file: %w", err)
|
||||
}
|
||||
@@ -291,6 +293,22 @@ func createConfig(configpath string, noInput bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateSetupConfigYAML(config *settings.Settings) (string, error) {
|
||||
embeddedPaths := []string{
|
||||
"frontend/public/config.generated.yaml",
|
||||
"../frontend/public/config.generated.yaml",
|
||||
"internal/web/dist/config.generated.yaml",
|
||||
"../internal/web/dist/config.generated.yaml",
|
||||
}
|
||||
for _, path := range embeddedPaths {
|
||||
embeddedYaml, err := os.ReadFile(path)
|
||||
if err == nil && len(embeddedYaml) > 0 {
|
||||
return settings.GenerateConfigYamlWithEmbedded(config, true, false, false, string(embeddedYaml))
|
||||
}
|
||||
}
|
||||
return settings.GenerateConfigYaml(config, true, false, false)
|
||||
}
|
||||
|
||||
func generateYaml() {
|
||||
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
|
||||
if generateConfig || settings.Env.IsDevMode {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -191,3 +192,26 @@ func TestSetupNoInputRejected(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "interactive input")
|
||||
}
|
||||
|
||||
func TestGenerateSetupConfigYAML_MinimalWithComments(t *testing.T) {
|
||||
t.Chdir("..")
|
||||
|
||||
cfg := settings.SetDefaults(false)
|
||||
cfg.Server.Sources = []*settings.Source{{Path: "/var/lib/files"}}
|
||||
cfg.Server.DatabaseV2.Path = "./filebrowser.sqlite"
|
||||
cfg.Http.Port = 8080
|
||||
cfg.Server.Logging = []settings.LogConfig{
|
||||
{Output: "stdout", Levels: "info|warning|error"},
|
||||
}
|
||||
|
||||
out, err := generateSetupConfigYAML(&cfg)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, out, "#")
|
||||
assert.Contains(t, out, "/var/lib/files")
|
||||
assert.Contains(t, out, "8080")
|
||||
assert.NotContains(t, out, "FileBrowser Quantum")
|
||||
assert.NotContains(t, out, "darkMode")
|
||||
assert.NotContains(t, out, "minSearchLength:")
|
||||
assert.NotContains(t, out, "frontend:")
|
||||
assert.NotContains(t, out, "rules:")
|
||||
}
|
||||
@@ -18,7 +18,6 @@ server:
|
||||
|
||||
userDefaults:
|
||||
fileViewer:
|
||||
editorQuickSave: false
|
||||
disableOnlyOfficeExt: ".md .txt .pdf"
|
||||
disableViewingExt: ""
|
||||
sidebar:
|
||||
|
||||
+1
-1
@@ -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 +0,0 @@
|
||||
../../frontend/dist
|
||||
@@ -53,7 +53,7 @@ func TestUserUpdateChangesFiltersUnchangedFields(t *testing.T) {
|
||||
which := []string{
|
||||
"preview", "stickySidebar", "darkMode", "locale", "singleClick", "sorting",
|
||||
"showHidden", "dateFormat", "themeColor", "quickDownload", "fileLoading",
|
||||
"sidebarLinks", "deleteAfterArchive", "preferEditorForMarkdown", "showFirstLogin",
|
||||
"sidebarLinks", "deleteAfterArchive", "preferEditorForMarkdown", "showFirstLogin", "requirePasswordChange",
|
||||
}
|
||||
|
||||
changes := UserUpdateChanges(before, &after, which, false)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package fileutils
|
||||
|
||||
// PartitionUsage is a capacity view of a source path: total and used bytes.
|
||||
type PartitionUsage struct {
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
}
|
||||
|
||||
// singlePathPartitionUsage statfs's a single path: the filesystem covering it.
|
||||
func singlePathPartitionUsage(path string) (PartitionUsage, error) {
|
||||
total, err := GetPartitionSize(path)
|
||||
if err != nil {
|
||||
return PartitionUsage{}, err
|
||||
}
|
||||
used, err := GetPartitionUsed(path)
|
||||
if err != nil {
|
||||
return PartitionUsage{}, err
|
||||
}
|
||||
return PartitionUsage{Total: total, Used: used}, nil
|
||||
}
|
||||
|
||||
// GetAggregatedPartitionUsage returns total and used bytes summed across distinct
|
||||
// capacity groups mounted at or under root. See GetPartitionUsageVariants.
|
||||
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
|
||||
aggregate, _, err := GetPartitionUsageVariants(root)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return aggregate.Total, aggregate.Used, nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//go:build freebsd
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// bsdMount is one Getfsstat record reduced to what capacity aggregation
|
||||
// needs: fstype, source, mountpoint, usage, and the mount's own fsid.
|
||||
type bsdMount struct {
|
||||
fstype string
|
||||
source string
|
||||
mountpoint string
|
||||
usage rawUsage
|
||||
fsid unix.Fsid
|
||||
}
|
||||
|
||||
// resolvedMount is what a path lookup reports for a mountpoint: the
|
||||
// mountpoint of the filesystem that actually owns the path and that
|
||||
// filesystem's fsid.
|
||||
type resolvedMount struct {
|
||||
mountpoint string
|
||||
fsid unix.Fsid
|
||||
ok bool
|
||||
}
|
||||
|
||||
// statfsMountpoint resolves which filesystem a mountpoint path lands on.
|
||||
func statfsMountpoint(mp string) resolvedMount {
|
||||
var st unix.Statfs_t
|
||||
if err := unix.Statfs(mp, &st); err != nil {
|
||||
return resolvedMount{}
|
||||
}
|
||||
return resolvedMount{
|
||||
mountpoint: filepath.Clean(unix.ByteSliceToString(st.Mntonname[:])),
|
||||
fsid: st.Fsid,
|
||||
ok: true,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPartitionUsageVariants returns the aggregate and root-only capacity views
|
||||
// of root. FreeBSD has no mountinfo; Getfsstat already carries per-mount statfs
|
||||
// data, so grouping happens on the result set directly (ZFS datasets grouped by
|
||||
// pool name, everything else by fsid).
|
||||
func GetPartitionUsageVariants(root string) (aggregate, rootOnly PartitionUsage, err error) {
|
||||
if root == "" {
|
||||
return aggregate, rootOnly, fmt.Errorf("empty path")
|
||||
}
|
||||
root = filepath.Clean(root)
|
||||
|
||||
rootOnly, rootErr := singlePathPartitionUsage(root)
|
||||
|
||||
count, statErr := unix.Getfsstat(nil, unix.MNT_WAIT)
|
||||
if statErr != nil || count <= 0 {
|
||||
if rootErr != nil {
|
||||
return aggregate, rootOnly, rootErr
|
||||
}
|
||||
return rootOnly, rootOnly, nil
|
||||
}
|
||||
stats := make([]unix.Statfs_t, count)
|
||||
if _, statErr = unix.Getfsstat(stats, unix.MNT_WAIT); statErr != nil {
|
||||
if rootErr != nil {
|
||||
return aggregate, rootOnly, rootErr
|
||||
}
|
||||
return rootOnly, rootOnly, nil
|
||||
}
|
||||
|
||||
var mounts []bsdMount
|
||||
for i := range stats {
|
||||
s := &stats[i]
|
||||
avail := uint64(0)
|
||||
if s.Bavail > 0 {
|
||||
avail = uint64(s.Bavail)
|
||||
}
|
||||
mounts = append(mounts, bsdMount{
|
||||
fstype: unix.ByteSliceToString(s.Fstypename[:]),
|
||||
source: unix.ByteSliceToString(s.Mntfromname[:]),
|
||||
mountpoint: filepath.Clean(unix.ByteSliceToString(s.Mntonname[:])),
|
||||
usage: rawUsage{
|
||||
total: s.Blocks * s.Bsize,
|
||||
avail: avail * s.Bsize,
|
||||
},
|
||||
fsid: s.Fsid,
|
||||
})
|
||||
}
|
||||
|
||||
covering, nested := selectCapacityMounts(mounts, root, statfsMountpoint)
|
||||
|
||||
key := func(m bsdMount) string {
|
||||
if isZFSType(m.fstype) {
|
||||
return "zfs:" + zfsPoolName(m.source)
|
||||
}
|
||||
if m.fsid.Val[0] == 0 && m.fsid.Val[1] == 0 {
|
||||
// fsid-less filesystems (e.g. some tmpfs mounts): keep per mountpoint.
|
||||
return "mp:" + m.mountpoint
|
||||
}
|
||||
return fmt.Sprintf("fsid:%x:%x", m.fsid.Val[0], m.fsid.Val[1])
|
||||
}
|
||||
|
||||
groups := make(map[string][]rawUsage)
|
||||
fstypes := make(map[string]string)
|
||||
add := func(m bsdMount) {
|
||||
k := key(m)
|
||||
fstypes[k] = m.fstype
|
||||
groups[k] = append(groups[k], m.usage)
|
||||
}
|
||||
if covering != nil {
|
||||
add(*covering)
|
||||
}
|
||||
for _, m := range nested {
|
||||
add(m)
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
if rootErr != nil {
|
||||
return aggregate, rootOnly, rootErr
|
||||
}
|
||||
return rootOnly, rootOnly, nil
|
||||
}
|
||||
|
||||
for k, stats := range groups {
|
||||
u := combineGroupUsage(stats)
|
||||
if !isZFSType(fstypes[k]) && len(stats) > 1 {
|
||||
// Non-shared-pool mounts dedupe to a single measurement.
|
||||
u = combineGroupUsage(stats[:1])
|
||||
}
|
||||
aggregate.Total += u.Total
|
||||
aggregate.Used += u.Used
|
||||
}
|
||||
return aggregate, rootOnly, rootErr
|
||||
}
|
||||
|
||||
// selectCapacityMounts picks the mounts contributing capacity under root: the
|
||||
// filesystem covering root plus mounts nested beneath it.
|
||||
//
|
||||
// Getfsstat retains records that path lookups no longer reach: mounts stacked
|
||||
// under a later mount at the same mountpoint, and mounts below an overmounted
|
||||
// ancestor (stacked records have no documented order, so position cannot
|
||||
// identify the visible one). A record counts only when a lookup on its
|
||||
// mountpoint still lands on it: the resolved mountpoint must equal the
|
||||
// record's and, for filesystems carrying an fsid, the resolved fsid must match
|
||||
// the record's (every stacked record resolves to the topmost fsid). fsid-less
|
||||
// records keep the first match at a mountpoint; stacked fsid-less mounts are
|
||||
// indistinguishable.
|
||||
func selectCapacityMounts(mounts []bsdMount, root string, lookup func(string) resolvedMount) (*bsdMount, []bsdMount) {
|
||||
var covering *bsdMount
|
||||
var nested []bsdMount
|
||||
claimed := make(map[string]bool)
|
||||
resolved := make(map[string]resolvedMount)
|
||||
for i := range mounts {
|
||||
m := mounts[i]
|
||||
covers := m.mountpoint == root || m.mountpoint == "/" || strings.HasPrefix(root, m.mountpoint+"/")
|
||||
nests := strings.HasPrefix(m.mountpoint, root+"/")
|
||||
if !covers && !nests {
|
||||
continue
|
||||
}
|
||||
r, seen := resolved[m.mountpoint]
|
||||
if !seen {
|
||||
r = lookup(m.mountpoint)
|
||||
resolved[m.mountpoint] = r
|
||||
}
|
||||
if !r.ok || r.mountpoint != m.mountpoint {
|
||||
continue
|
||||
}
|
||||
if m.fsid.Val[0] != 0 || m.fsid.Val[1] != 0 {
|
||||
if r.fsid != m.fsid {
|
||||
continue
|
||||
}
|
||||
} else if claimed[m.mountpoint] {
|
||||
continue
|
||||
}
|
||||
claimed[m.mountpoint] = true
|
||||
if covers && (covering == nil || len(m.mountpoint) > len(covering.mountpoint)) {
|
||||
c := m
|
||||
covering = &c
|
||||
}
|
||||
if nests {
|
||||
if isZFSType(m.fstype) && isZFSSnapshotMount(m.source, m.mountpoint) {
|
||||
continue
|
||||
}
|
||||
nested = append(nested, m)
|
||||
}
|
||||
}
|
||||
return covering, nested
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//go:build freebsd
|
||||
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func fsidVal(a, b int32) unix.Fsid {
|
||||
return unix.Fsid{Val: [2]int32{a, b}}
|
||||
}
|
||||
|
||||
func resolvedOK(mp string, fsid unix.Fsid) resolvedMount {
|
||||
return resolvedMount{mountpoint: mp, fsid: fsid, ok: true}
|
||||
}
|
||||
|
||||
// TestSelectCapacityMountsOvermountedAncestorHidesNested reproduces the
|
||||
// reviewed layout: /srv/child was mounted first, then /srv was overmounted.
|
||||
// Getfsstat still reports the unique /srv/child record, but a lookup of that
|
||||
// path lands inside the covering filesystem, so its usage must be excluded.
|
||||
func TestSelectCapacityMountsOvermountedAncestorHidesNested(t *testing.T) {
|
||||
rootFs, childFs, coverFs := fsidVal(1, 1), fsidVal(2, 2), fsidVal(3, 3)
|
||||
mounts := []bsdMount{
|
||||
{fstype: "ufs", source: "/dev/da0p2", mountpoint: "/", fsid: rootFs, usage: rawUsage{total: 100, avail: 50}},
|
||||
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/srv/child", fsid: childFs, usage: rawUsage{total: 200, avail: 100}},
|
||||
{fstype: "ufs", source: "/dev/da2p1", mountpoint: "/srv", fsid: coverFs, usage: rawUsage{total: 300, avail: 150}},
|
||||
}
|
||||
lookup := func(mp string) resolvedMount {
|
||||
switch mp {
|
||||
case "/":
|
||||
return resolvedOK("/", rootFs)
|
||||
case "/srv":
|
||||
return resolvedOK("/srv", coverFs)
|
||||
case "/srv/child":
|
||||
// Hidden: resolves to the covering filesystem at /srv.
|
||||
return resolvedOK("/srv", coverFs)
|
||||
}
|
||||
return resolvedMount{}
|
||||
}
|
||||
|
||||
covering, nested := selectCapacityMounts(mounts, "/srv", lookup)
|
||||
if covering == nil || covering.mountpoint != "/srv" {
|
||||
t.Fatalf("covering = %+v, want mountpoint /srv", covering)
|
||||
}
|
||||
if len(nested) != 0 {
|
||||
t.Fatalf("nested = %+v, want hidden /srv/child excluded", nested)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectCapacityMountsUnreachableNestedSkipped covers the same hidden
|
||||
// layout when the covered path no longer exists in the new filesystem: the
|
||||
// lookup fails outright and the stale record is dropped.
|
||||
func TestSelectCapacityMountsUnreachableNestedSkipped(t *testing.T) {
|
||||
rootFs, coverFs := fsidVal(1, 1), fsidVal(3, 3)
|
||||
mounts := []bsdMount{
|
||||
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
|
||||
{fstype: "ufs", source: "/dev/da2p1", mountpoint: "/srv", fsid: coverFs},
|
||||
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/srv/gone", fsid: fsidVal(2, 2), usage: rawUsage{total: 200, avail: 100}},
|
||||
}
|
||||
lookup := func(mp string) resolvedMount {
|
||||
switch mp {
|
||||
case "/":
|
||||
return resolvedOK("/", rootFs)
|
||||
case "/srv":
|
||||
return resolvedOK("/srv", coverFs)
|
||||
}
|
||||
return resolvedMount{} // ENOENT under the overmount
|
||||
}
|
||||
|
||||
_, nested := selectCapacityMounts(mounts, "/srv", lookup)
|
||||
if len(nested) != 0 {
|
||||
t.Fatalf("nested = %+v, want unreachable /srv/gone excluded", nested)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectCapacityMountsStackedTopmostOnly checks that of several records
|
||||
// at one mountpoint only the one a lookup resolves to is counted.
|
||||
func TestSelectCapacityMountsStackedTopmostOnly(t *testing.T) {
|
||||
rootFs, lowerFs, upperFs := fsidVal(1, 1), fsidVal(4, 4), fsidVal(5, 5)
|
||||
mounts := []bsdMount{
|
||||
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
|
||||
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/srv/data", fsid: lowerFs, usage: rawUsage{total: 200, avail: 100}},
|
||||
{fstype: "tmpfs", source: "tmpfs", mountpoint: "/srv/data", fsid: upperFs, usage: rawUsage{total: 60, avail: 30}},
|
||||
}
|
||||
lookup := func(mp string) resolvedMount {
|
||||
switch mp {
|
||||
case "/":
|
||||
return resolvedOK("/", rootFs)
|
||||
case "/srv/data":
|
||||
return resolvedOK("/srv/data", upperFs)
|
||||
}
|
||||
return resolvedMount{}
|
||||
}
|
||||
|
||||
_, nested := selectCapacityMounts(mounts, "/srv", lookup)
|
||||
if len(nested) != 1 || nested[0].fsid != upperFs {
|
||||
t.Fatalf("nested = %+v, want only the topmost /srv/data record", nested)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectCapacityMountsHiddenCoveringAncestor: when the deepest ancestor
|
||||
// mount of root is itself hidden by an overmount, covering must fall back to
|
||||
// the deepest visible ancestor.
|
||||
func TestSelectCapacityMountsHiddenCoveringAncestor(t *testing.T) {
|
||||
rootFs, overFs, staleFs := fsidVal(1, 1), fsidVal(8, 8), fsidVal(9, 9)
|
||||
mounts := []bsdMount{
|
||||
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
|
||||
{fstype: "ufs", source: "/dev/da2p1", mountpoint: "/x", fsid: overFs},
|
||||
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/x/y", fsid: staleFs, usage: rawUsage{total: 200, avail: 100}},
|
||||
}
|
||||
// /x was mounted over the parent chain of /x/y: lookups of /x/y resolve
|
||||
// inside the /x filesystem.
|
||||
lookup := func(mp string) resolvedMount {
|
||||
switch mp {
|
||||
case "/":
|
||||
return resolvedOK("/", rootFs)
|
||||
case "/x":
|
||||
return resolvedOK("/x", overFs)
|
||||
case "/x/y":
|
||||
return resolvedOK("/x", overFs)
|
||||
}
|
||||
return resolvedMount{}
|
||||
}
|
||||
|
||||
covering, nested := selectCapacityMounts(mounts, "/x/y/z", lookup)
|
||||
if covering == nil || covering.mountpoint != "/x" {
|
||||
t.Fatalf("covering = %+v, want deepest visible ancestor /x", covering)
|
||||
}
|
||||
if len(nested) != 0 {
|
||||
t.Fatalf("nested = %+v, want none", nested)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectCapacityMountsNestedVisibleKept is the baseline: a nested mount
|
||||
// that still resolves to itself contributes its usage.
|
||||
func TestSelectCapacityMountsNestedVisibleKept(t *testing.T) {
|
||||
rootFs, zfsFs := fsidVal(1, 1), fsidVal(7, 7)
|
||||
mounts := []bsdMount{
|
||||
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
|
||||
{fstype: "zfs", source: "tank/data", mountpoint: "/srv/data", fsid: zfsFs, usage: rawUsage{total: 400, avail: 200}},
|
||||
}
|
||||
lookup := func(mp string) resolvedMount {
|
||||
switch mp {
|
||||
case "/":
|
||||
return resolvedOK("/", rootFs)
|
||||
case "/srv/data":
|
||||
return resolvedOK("/srv/data", zfsFs)
|
||||
}
|
||||
return resolvedMount{}
|
||||
}
|
||||
|
||||
covering, nested := selectCapacityMounts(mounts, "/srv", lookup)
|
||||
if covering == nil || covering.mountpoint != "/" {
|
||||
t.Fatalf("covering = %+v, want /", covering)
|
||||
}
|
||||
if len(nested) != 1 || nested[0].mountpoint != "/srv/data" {
|
||||
t.Fatalf("nested = %+v, want [/srv/data]", nested)
|
||||
}
|
||||
}
|
||||
@@ -7,94 +7,64 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// GetAggregatedPartitionUsage returns total and used bytes summed across distinct
|
||||
// filesystems mounted at or under root (deduped by major:minor from mountinfo).
|
||||
// Falls back to a single-path probe when mountinfo cannot be read.
|
||||
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
|
||||
// GetPartitionUsageVariants returns two capacity views of root:
|
||||
// - aggregate: total/used summed across distinct capacity groups mounted at or
|
||||
// under root, deduped so shared-pool filesystems (ZFS datasets, btrfs
|
||||
// subvolumes) are counted once per pool.
|
||||
// - rootOnly: a single statfs of root, ignoring nested mounts.
|
||||
//
|
||||
// Falls back to the single-path probe for both when mountinfo cannot be read.
|
||||
func GetPartitionUsageVariants(root string) (aggregate, rootOnly PartitionUsage, err error) {
|
||||
if root == "" {
|
||||
return 0, 0, fmt.Errorf("empty path")
|
||||
return aggregate, rootOnly, fmt.Errorf("empty path")
|
||||
}
|
||||
root = filepath.Clean(root)
|
||||
|
||||
paths, parseErr := distinctMountPathsUnder(root)
|
||||
if parseErr != nil || len(paths) == 0 {
|
||||
return singlePathPartitionUsage(root)
|
||||
rootOnly, rootErr := singlePathPartitionUsage(root)
|
||||
|
||||
groups, parseErr := mountGroupsUnder(root)
|
||||
if parseErr != nil || len(groups) == 0 {
|
||||
if rootErr != nil {
|
||||
return aggregate, rootOnly, rootErr
|
||||
}
|
||||
return rootOnly, rootOnly, nil
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for _, path := range paths {
|
||||
t, u, stErr := partitionUsageAt(path)
|
||||
if stErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = stErr
|
||||
for _, group := range groups {
|
||||
stats := make([]rawUsage, 0, len(group.paths))
|
||||
for _, p := range group.paths {
|
||||
stat, stErr := partitionStatfs(p)
|
||||
if stErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = stErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
continue
|
||||
stats = append(stats, stat)
|
||||
}
|
||||
total += t
|
||||
used += u
|
||||
u := combineGroupUsage(stats)
|
||||
aggregate.Total += u.Total
|
||||
aggregate.Used += u.Used
|
||||
}
|
||||
|
||||
if total == 0 && used == 0 {
|
||||
if aggregate.Total == 0 && aggregate.Used == 0 {
|
||||
if firstErr != nil {
|
||||
return 0, 0, firstErr
|
||||
return aggregate, rootOnly, firstErr
|
||||
}
|
||||
return singlePathPartitionUsage(root)
|
||||
aggregate = rootOnly
|
||||
}
|
||||
return total, used, nil
|
||||
return aggregate, rootOnly, rootErr
|
||||
}
|
||||
|
||||
func singlePathPartitionUsage(root string) (total, used uint64, err error) {
|
||||
total, err = GetPartitionSize(root)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
used, err = GetPartitionUsed(root)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return total, used, nil
|
||||
}
|
||||
|
||||
func distinctMountPathsUnder(root string) ([]string, error) {
|
||||
func mountGroupsUnder(root string) ([]mountGroup, error) {
|
||||
data, err := readMountinfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths, err := distinctMountPathsFromMountinfo(data, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If mountinfo had no covering mount for root, ensure root is probed.
|
||||
if len(paths) == 0 {
|
||||
return []string{root}, nil
|
||||
}
|
||||
hasRoot := false
|
||||
for _, p := range paths {
|
||||
if p == root {
|
||||
hasRoot = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRoot {
|
||||
if dev, stErr := deviceIDFromPath(root); stErr == nil {
|
||||
// Avoid duplicating a device already selected under another path.
|
||||
already := false
|
||||
for _, p := range paths {
|
||||
if d, e := deviceIDFromPath(p); e == nil && d == dev {
|
||||
already = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !already {
|
||||
paths = append(paths, root)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
return mountGroupsFromMountinfo(data, root)
|
||||
}
|
||||
|
||||
func readMountinfo() (string, error) {
|
||||
@@ -107,24 +77,14 @@ func readMountinfo() (string, error) {
|
||||
return "", fmt.Errorf("mountinfo not available")
|
||||
}
|
||||
|
||||
func deviceIDFromPath(path string) (string, error) {
|
||||
var st unix.Stat_t
|
||||
if err := unix.Stat(path, &st); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%d:%d", unix.Major(uint64(st.Dev)), unix.Minor(uint64(st.Dev))), nil
|
||||
}
|
||||
|
||||
func partitionUsageAt(path string) (total, used uint64, err error) {
|
||||
func partitionStatfs(path string) (rawUsage, error) {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return 0, 0, err
|
||||
return rawUsage{}, err
|
||||
}
|
||||
bsize := blockSize(&stat)
|
||||
total = uint64(stat.Blocks) * bsize
|
||||
free := uint64(stat.Bavail) * bsize
|
||||
if total >= free {
|
||||
used = total - free
|
||||
}
|
||||
return total, used, nil
|
||||
return rawUsage{
|
||||
total: uint64(stat.Blocks) * bsize,
|
||||
avail: uint64(stat.Bavail) * bsize,
|
||||
}, nil
|
||||
}
|
||||
@@ -12,55 +12,111 @@ import (
|
||||
type mountEntry struct {
|
||||
deviceID string
|
||||
mountpoint string
|
||||
fstype string
|
||||
source string
|
||||
}
|
||||
|
||||
// distinctMountPathsFromMountinfo selects one path per distinct filesystem that
|
||||
// contributes capacity for root: the FS that owns root, plus nested mounts under
|
||||
// root. Deduped by major:minor from mountinfo.
|
||||
func distinctMountPathsFromMountinfo(mountinfo string, root string) ([]string, error) {
|
||||
// mountGroup is a set of mountpoints that share one capacity pool. Groups keyed
|
||||
// by filesystem identity get one probe path; shared-pool filesystems (zfs) keep
|
||||
// every dataset mountpoint so per-dataset used space can be summed.
|
||||
type mountGroup struct {
|
||||
key string
|
||||
fstype string
|
||||
paths []string
|
||||
}
|
||||
|
||||
// capacityKey identifies mounts that share the same underlying capacity.
|
||||
// - zfs: every dataset has its own device ID but draws from the pool; group by pool.
|
||||
// - btrfs: subvolume mounts have distinct device IDs but report the whole
|
||||
// filesystem; group by mount source.
|
||||
// - everything else: device major:minor, as before.
|
||||
func (e *mountEntry) capacityKey() string {
|
||||
switch e.fstype {
|
||||
case "zfs", "zfsfuse":
|
||||
return "zfs:" + zfsPoolName(e.source)
|
||||
case "btrfs":
|
||||
return "btrfs:" + e.source
|
||||
default:
|
||||
return "dev:" + e.deviceID
|
||||
}
|
||||
}
|
||||
|
||||
// mountGroupsFromMountinfo selects the mounts contributing capacity for root:
|
||||
// the filesystem that owns root, plus nested mounts under root, grouped by
|
||||
// capacity key. ZFS snapshot mounts are excluded.
|
||||
func mountGroupsFromMountinfo(mountinfo string, root string) ([]mountGroup, error) {
|
||||
root = path.Clean(root)
|
||||
entries, err := parseAllMounts(mountinfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]string)
|
||||
groupIndex := make(map[string]int)
|
||||
var groups []mountGroup
|
||||
addPath := func(e *mountEntry, p string) {
|
||||
key := e.capacityKey()
|
||||
i, ok := groupIndex[key]
|
||||
if !ok {
|
||||
i = len(groups)
|
||||
groupIndex[key] = i
|
||||
groups = append(groups, mountGroup{key: key, fstype: e.fstype})
|
||||
}
|
||||
// Only shared-pool filesystems (zfs) need every mountpoint; other groups
|
||||
// report identical statfs per mount, so one probe path suffices.
|
||||
if !isZFSType(groups[i].fstype) && len(groups[i].paths) > 0 {
|
||||
return
|
||||
}
|
||||
for _, existing := range groups[i].paths {
|
||||
if existing == p {
|
||||
return
|
||||
}
|
||||
}
|
||||
groups[i].paths = append(groups[i].paths, p)
|
||||
}
|
||||
|
||||
var covering *mountEntry
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
mp := e.mountpoint
|
||||
if mp == root || strings.HasPrefix(root, mp+"/") || mp == "/" {
|
||||
if covering == nil || len(mp) > len(covering.mountpoint) {
|
||||
// >= keeps the later entry: stacked mounts at one mountpoint are
|
||||
// shadowed by the topmost (last in mountinfo order).
|
||||
if covering == nil || len(mp) >= len(covering.mountpoint) {
|
||||
covering = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if covering != nil {
|
||||
seen[covering.deviceID] = root
|
||||
// Probe root itself for the covering filesystem: root may be a plain
|
||||
// directory inside a mount whose mountpoint is an ancestor.
|
||||
addPath(covering, root)
|
||||
} else {
|
||||
// No mount covers root (e.g. hidden bind mount); probe it directly so
|
||||
// it is not dropped when nested mounts under root do exist.
|
||||
groups = append(groups, mountGroup{key: "self:" + root, paths: []string{root}})
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
// Stacked mounts: only the topmost (last in mountinfo order) is reachable.
|
||||
lastAtMountpoint := make(map[string]int)
|
||||
for i := range entries {
|
||||
lastAtMountpoint[entries[i].mountpoint] = i
|
||||
}
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
mp := e.mountpoint
|
||||
if mp != root && !strings.HasPrefix(mp, root+"/") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[e.deviceID]; ok {
|
||||
if lastAtMountpoint[mp] != i {
|
||||
continue
|
||||
}
|
||||
seen[e.deviceID] = mp
|
||||
if isZFSType(e.fstype) && isZFSSnapshotMount(e.source, mp) {
|
||||
continue
|
||||
}
|
||||
addPath(e, mp)
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(seen))
|
||||
uniq := make(map[string]struct{}, len(seen))
|
||||
for _, p := range seen {
|
||||
if _, ok := uniq[p]; ok {
|
||||
continue
|
||||
}
|
||||
uniq[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func parseAllMounts(mountinfo string) ([]mountEntry, error) {
|
||||
@@ -88,9 +144,16 @@ func parseMountinfoLine(line string) (mountEntry, bool) {
|
||||
if len(fields) < 5 {
|
||||
return mountEntry{}, false
|
||||
}
|
||||
// After " - ": "fstype source super-options".
|
||||
post := strings.Fields(parts[1])
|
||||
if len(post) < 2 {
|
||||
return mountEntry{}, false
|
||||
}
|
||||
return mountEntry{
|
||||
deviceID: fields[2],
|
||||
mountpoint: unescapeFstab(fields[4]),
|
||||
fstype: post[0],
|
||||
source: unescapeFstab(post[1]),
|
||||
}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,25 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// allGroupPaths flattens groups into a sorted list of probe paths.
|
||||
func allGroupPaths(groups []mountGroup) []string {
|
||||
var out []string
|
||||
for _, g := range groups {
|
||||
out = append(out, g.paths...)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func findGroup(groups []mountGroup, prefix string) *mountGroup {
|
||||
for i := range groups {
|
||||
if len(groups[i].key) >= len(prefix) && groups[i].key[:len(prefix)] == prefix {
|
||||
return &groups[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDistinctMountPathsNestedDisk(t *testing.T) {
|
||||
// /srv lives on rootfs 8:1; /srv/data is a separate 2TB disk 8:16.
|
||||
mountinfo := "" +
|
||||
@@ -14,11 +33,11 @@ func TestDistinctMountPathsNestedDisk(t *testing.T) {
|
||||
"45 22 8:16 /data /srv/data rw - btrfs /dev/sdb1 rw\n" +
|
||||
"50 22 0:47 / /proc rw - proc proc rw\n"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
paths := allGroupPaths(groups)
|
||||
want := []string{"/srv", "/srv/data"}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("paths = %v, want %v", paths, want)
|
||||
@@ -37,11 +56,11 @@ func TestDistinctMountPathsOvermountSamePathDeduped(t *testing.T) {
|
||||
"45 40 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n" +
|
||||
"46 45 0:47 / /srv/data rw - tmpfs tmpfs rw\n"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
paths := allGroupPaths(groups)
|
||||
want := []string{"/srv", "/srv/data"}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("paths = %v, want %v (no duplicate probe paths)", paths, want)
|
||||
@@ -56,15 +75,15 @@ func TestDistinctMountPathsOvermountSamePathDeduped(t *testing.T) {
|
||||
func TestDistinctMountPathsBindSameDeviceNotDuplicated(t *testing.T) {
|
||||
mountinfo := "" +
|
||||
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
|
||||
"45 22 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n" +
|
||||
"46 22 8:16 / /srv/data/bind rw,bind - btrfs /dev/sdb1 rw\n"
|
||||
"45 22 8:16 / /srv/data rw - ext4 /dev/sdb1 rw\n" +
|
||||
"46 22 8:16 / /srv/data/bind rw,bind - ext4 /dev/sdb1 rw\n"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(paths) != 2 {
|
||||
t.Fatalf("expected 2 distinct devices, got %v", paths)
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("expected 2 distinct capacity groups, got %v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,11 +93,11 @@ func TestDistinctMountPathsSourceIsOwnMount(t *testing.T) {
|
||||
"40 22 8:2 / /srv rw - ext4 /dev/sda2 rw\n" +
|
||||
"45 40 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
paths := allGroupPaths(groups)
|
||||
want := []string{"/srv", "/srv/data"}
|
||||
if len(paths) != 2 || paths[0] != want[0] || paths[1] != want[1] {
|
||||
t.Fatalf("paths = %v, want %v", paths, want)
|
||||
@@ -90,10 +109,11 @@ func TestDistinctMountPathsIgnoresUnrelated(t *testing.T) {
|
||||
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
|
||||
"30 22 8:3 / /home rw - ext4 /dev/sda3 rw\n"
|
||||
|
||||
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
|
||||
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths := allGroupPaths(groups)
|
||||
if len(paths) != 1 || paths[0] != "/srv" {
|
||||
t.Fatalf("paths = %v, want [/srv]", paths)
|
||||
}
|
||||
@@ -110,4 +130,124 @@ func TestParseMountinfoEscapedSpace(t *testing.T) {
|
||||
if entry.deviceID != "98:0" {
|
||||
t.Fatalf("deviceID = %q, want 98:0", entry.deviceID)
|
||||
}
|
||||
if entry.fstype != "ext3" {
|
||||
t.Fatalf("fstype = %q, want ext3", entry.fstype)
|
||||
}
|
||||
if entry.source != "/dev/root" {
|
||||
t.Fatalf("source = %q, want /dev/root", entry.source)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMountGroupsZFSPoolDatasetsGrouped reproduces issue #3025: every ZFS
|
||||
// dataset gets its own device ID, so major:minor dedup counts pool capacity
|
||||
// once per dataset. They must share one capacity group.
|
||||
func TestMountGroupsZFSPoolDatasetsGrouped(t *testing.T) {
|
||||
mountinfo := "" +
|
||||
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
|
||||
"40 22 0:55 / /srv/pool rw - zfs pool rw\n" +
|
||||
"41 40 0:56 / /srv/pool/ds01 rw - zfs pool/ds01 rw\n" +
|
||||
"42 41 0:57 / /srv/pool/ds01/a rw - zfs pool/ds01/a rw\n" +
|
||||
"43 40 0:58 / /srv/pool/ds02 rw - zfs pool/ds02 rw\n" +
|
||||
"44 22 0:59 / /other/pool2 rw - zfs pool2 rw\n"
|
||||
|
||||
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv/pool")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := findGroup(groups, "zfs:pool")
|
||||
if g == nil || g.key != "zfs:pool" {
|
||||
t.Fatalf("expected one zfs:pool group, got %v", groups)
|
||||
}
|
||||
want := []string{"/srv/pool", "/srv/pool/ds01", "/srv/pool/ds01/a", "/srv/pool/ds02"}
|
||||
got := append([]string{}, g.paths...)
|
||||
sort.Strings(got)
|
||||
sort.Strings(want)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("zfs group paths = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("zfs group paths = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
// The unrelated second pool must not join the group.
|
||||
if g2 := findGroup(groups, "zfs:pool2"); g2 != nil {
|
||||
t.Fatalf("unrelated pool2 should not be included: %v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountGroupsZFSSnapshotsSkipped(t *testing.T) {
|
||||
mountinfo := "" +
|
||||
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
|
||||
"40 22 0:55 / /srv/pool rw - zfs pool rw\n" +
|
||||
"41 40 0:56 / /srv/pool/ds01 rw - zfs pool/ds01 rw\n" +
|
||||
"42 41 0:57 / /srv/pool/ds01/.zfs/snapshot/snap1 rw - zfs pool/ds01@snap1 rw\n"
|
||||
|
||||
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv/pool")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths := allGroupPaths(groups)
|
||||
for _, p := range paths {
|
||||
if p == "/srv/pool/ds01/.zfs/snapshot/snap1" {
|
||||
t.Fatalf("snapshot mount should be skipped: %v", paths)
|
||||
}
|
||||
}
|
||||
if len(paths) != 2 {
|
||||
t.Fatalf("paths = %v, want 2 live datasets", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountGroupsBtrfsSubvolumesDeduped(t *testing.T) {
|
||||
// Each btrfs subvolume mount has a distinct anonymous device but reports
|
||||
// whole-filesystem statfs; they must collapse into one group.
|
||||
mountinfo := "" +
|
||||
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
|
||||
"40 22 0:33 /@ /srv rw - btrfs /dev/sdb1 rw\n" +
|
||||
"41 40 0:34 /@home /srv/home rw - btrfs /dev/sdb1 rw\n" +
|
||||
"42 40 0:35 /@data /srv/data rw - btrfs /dev/sdb1 rw\n"
|
||||
|
||||
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := findGroup(groups, "btrfs:")
|
||||
if g == nil || g.key != "btrfs:/dev/sdb1" {
|
||||
t.Fatalf("expected one btrfs group, got %v", groups)
|
||||
}
|
||||
// Subvolume mounts report identical fs-wide statfs; one probe path suffices.
|
||||
if len(g.paths) != 1 || g.paths[0] != "/srv" {
|
||||
t.Fatalf("btrfs group paths = %v, want [/srv]", g.paths)
|
||||
}
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("expected exactly 1 group for /srv, got %v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCombineGroupUsageZFSPool verifies the arithmetic from #3025: 26 datasets
|
||||
// each reporting ~19.7T avail must yield used+19.7T total, not 26*19.7T+used.
|
||||
func TestCombineGroupUsageZFSPool(t *testing.T) {
|
||||
const avail = uint64(19_700_000_000_000) // ~19.7 TiB pool free
|
||||
stats := []rawUsage{{total: 20_000_000_000_000, avail: avail}} // root ds: 0.3T used
|
||||
for i := 0; i < 25; i++ {
|
||||
stats = append(stats, rawUsage{total: avail + 100_000_000_000, avail: avail})
|
||||
}
|
||||
// One quota'd child: reports a smaller avail; must not shrink the pool.
|
||||
stats = append(stats, rawUsage{total: 1_000_000_000_000, avail: 500_000_000_000})
|
||||
|
||||
u := combineGroupUsage(stats)
|
||||
wantUsed := uint64(300_000_000_000) + 25*uint64(100_000_000_000) + 500_000_000_000
|
||||
if u.Used != wantUsed {
|
||||
t.Fatalf("used = %d, want %d", u.Used, wantUsed)
|
||||
}
|
||||
if u.Total != wantUsed+avail {
|
||||
t.Fatalf("total = %d, want %d (used+single pool avail)", u.Total, wantUsed+avail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineGroupUsageSingleMountIdentity(t *testing.T) {
|
||||
u := combineGroupUsage([]rawUsage{{total: 1000, avail: 400}})
|
||||
if u.Total != 1000 || u.Used != 600 {
|
||||
t.Fatalf("single mount = %+v, want total 1000 used 600", u)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
//go:build !linux
|
||||
//go:build !linux && !freebsd
|
||||
|
||||
package fileutils
|
||||
|
||||
// GetAggregatedPartitionUsage returns total and used bytes for the filesystem
|
||||
// that owns root. Non-Linux builds use a single statfs/GetDiskFreeSpaceEx path.
|
||||
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
|
||||
total, err = GetPartitionSize(root)
|
||||
// GetPartitionUsageVariants returns the aggregate and root-only capacity views
|
||||
// of root. Without mount info only a single statfs of the source path is
|
||||
// available, so both variants are identical.
|
||||
func GetPartitionUsageVariants(root string) (aggregate, rootOnly PartitionUsage, err error) {
|
||||
u, err := singlePathPartitionUsage(root)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return aggregate, rootOnly, err
|
||||
}
|
||||
used, err = GetPartitionUsed(root)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return total, used, nil
|
||||
return u, u, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build linux || freebsd
|
||||
|
||||
package fileutils
|
||||
|
||||
import "strings"
|
||||
|
||||
// rawUsage is a single filesystem measurement in bytes.
|
||||
type rawUsage struct {
|
||||
total uint64
|
||||
avail uint64
|
||||
}
|
||||
|
||||
// combineGroupUsage folds statfs results for mounts that share one capacity pool
|
||||
// into a single total/used pair. Per-mount used (total-avail) is summed, while
|
||||
// shared free space is counted once via the largest reported avail. This keeps
|
||||
// ZFS datasets or btrfs subvolumes from multiplying the pool's capacity by the
|
||||
// number of mounts under a source.
|
||||
func combineGroupUsage(stats []rawUsage) PartitionUsage {
|
||||
var used, avail uint64
|
||||
for _, s := range stats {
|
||||
if s.total > s.avail {
|
||||
used += s.total - s.avail
|
||||
}
|
||||
if s.avail > avail {
|
||||
avail = s.avail
|
||||
}
|
||||
}
|
||||
return PartitionUsage{Total: used + avail, Used: used}
|
||||
}
|
||||
|
||||
// isZFSType reports whether fstype is a ZFS variant whose datasets share pool
|
||||
// capacity across mounts (kernel zfs or zfsfuse).
|
||||
func isZFSType(fstype string) bool {
|
||||
return fstype == "zfs" || fstype == "zfsfuse"
|
||||
}
|
||||
|
||||
// zfsPoolName returns the pool component of a ZFS mount source ("pool/ds@snap" -> "pool").
|
||||
func zfsPoolName(source string) string {
|
||||
if i := strings.IndexAny(source, "/@"); i >= 0 {
|
||||
return source[:i]
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
// isZFSSnapshotMount reports whether a ZFS mount is a snapshot (.zfs snapdir or
|
||||
// an "@snapshot" source). Snapshots share pool space with the live datasets and
|
||||
// are excluded from capacity aggregation.
|
||||
func isZFSSnapshotMount(source, mountpoint string) bool {
|
||||
return strings.Contains(source, "@") || strings.Contains(mountpoint, "/.zfs/")
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Package archiveencoding decodes ZIP entry names before path normalization.
|
||||
package archiveencoding
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
"golang.org/x/text/encoding/japanese"
|
||||
"golang.org/x/text/encoding/korean"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/encoding/traditionalchinese"
|
||||
)
|
||||
|
||||
// ErrEncoding indicates a filename encoding that needs a different user selection.
|
||||
var ErrEncoding = errors.New("invalid ZIP filename encoding")
|
||||
|
||||
type codec struct {
|
||||
name string
|
||||
encoding encoding.Encoding
|
||||
}
|
||||
|
||||
var codecs = []codec{
|
||||
{"utf-8", nil},
|
||||
{"cp932", japanese.ShiftJIS},
|
||||
{"gb18030", simplifiedchinese.GB18030},
|
||||
{"big5", traditionalchinese.Big5},
|
||||
{"euc-kr", korean.EUCKR},
|
||||
{"cp437", charmap.CodePage437},
|
||||
{"windows-1252", charmap.Windows1252},
|
||||
}
|
||||
|
||||
// Candidate contains a bounded preview, decoded exactly as during extraction.
|
||||
type Candidate struct {
|
||||
Encoding string `json:"encoding"`
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
|
||||
// Preview describes compatible encodings. An empty suggestion requires a choice;
|
||||
// a heuristic suggestion is never a guarantee that the names are correct.
|
||||
type Preview struct {
|
||||
Suggested string `json:"suggested"`
|
||||
Candidates []Candidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// unicodeName prefers the UTF-8 flag, then a verified Info-ZIP Unicode Path field.
|
||||
// A Unicode Path field is usable only when its version and original-name CRC match.
|
||||
func unicodeName(f *zip.File) (string, bool) {
|
||||
if f.Flags&0x800 != 0 {
|
||||
return f.Name, true
|
||||
}
|
||||
extra := f.Extra
|
||||
for len(extra) >= 4 {
|
||||
tag := binary.LittleEndian.Uint16(extra)
|
||||
size := int(binary.LittleEndian.Uint16(extra[2:]))
|
||||
extra = extra[4:]
|
||||
if size > len(extra) {
|
||||
break
|
||||
}
|
||||
field := extra[:size]
|
||||
extra = extra[size:]
|
||||
if tag == 0x7075 && len(field) > 5 && field[0] == 1 &&
|
||||
binary.LittleEndian.Uint32(field[1:5]) == crc32.ChecksumIEEE([]byte(f.Name)) && utf8.Valid(field[5:]) {
|
||||
return string(field[5:]), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func decode(f *zip.File, c codec) (string, error) {
|
||||
name, declared := unicodeName(f)
|
||||
if !declared {
|
||||
name = f.Name
|
||||
if c.encoding != nil {
|
||||
var err error
|
||||
name, err = c.encoding.NewDecoder().String(name)
|
||||
if err != nil || strings.ContainsRune(name, utf8.RuneError) {
|
||||
return "", fmt.Errorf("%w: cannot decode filename as %s", ErrEncoding, c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !utf8.ValidString(name) || strings.ContainsRune(name, 0) {
|
||||
return "", fmt.Errorf("%w: invalid filename for %s", ErrEncoding, c.name)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// Inspect considers all entry names without decompressing file contents. Candidate
|
||||
// previews prefer names lacking Unicode metadata so that users can compare them.
|
||||
func Inspect(files []*zip.File) Preview {
|
||||
result := Preview{Candidates: []Candidate{}}
|
||||
bestScore, tied := 0, false
|
||||
for _, c := range codecs {
|
||||
candidate := Candidate{Encoding: c.name, Names: []string{}}
|
||||
fallback := []string{}
|
||||
score, valid := 0, true
|
||||
for _, f := range files {
|
||||
name, err := decode(f, c)
|
||||
if err != nil {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
if len(fallback) < 5 {
|
||||
fallback = append(fallback, name)
|
||||
}
|
||||
if _, declared := unicodeName(f); !declared && !isASCII(f.Name) {
|
||||
if len(candidate.Names) < 5 {
|
||||
candidate.Names = append(candidate.Names, name)
|
||||
}
|
||||
// The EUC-KR decoder also accepts CP949 extension bytes, including
|
||||
// CP932 kana pairs. Do not use those bytes as Korean evidence.
|
||||
if c.name != "euc-kr" || isEUCKR(f.Name) {
|
||||
score += scriptEvidence(name, c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
if len(candidate.Names) == 0 {
|
||||
candidate.Names = fallback
|
||||
}
|
||||
result.Candidates = append(result.Candidates, candidate)
|
||||
// Valid UTF-8 (including metadata-backed names) needs no legacy guess.
|
||||
if c.name == "utf-8" {
|
||||
result.Suggested = c.name
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore, tied = score, false
|
||||
if result.Suggested != "utf-8" {
|
||||
result.Suggested = c.name
|
||||
}
|
||||
} else if score == bestScore && score > 0 {
|
||||
tied = true
|
||||
}
|
||||
}
|
||||
if result.Suggested != "utf-8" && (bestScore < 3 || tied) {
|
||||
result.Suggested = ""
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= utf8.RuneSelf {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isEUCKR excludes CP949 extension sequences from automatic Korean suggestions.
|
||||
func isEUCKR(raw string) bool {
|
||||
for i := 0; i < len(raw); i++ {
|
||||
if raw[i] < utf8.RuneSelf {
|
||||
continue
|
||||
}
|
||||
if raw[i] < 0xa1 || raw[i] > 0xfe || i+1 >= len(raw) || raw[i+1] < 0xa1 || raw[i+1] > 0xfe {
|
||||
return false
|
||||
}
|
||||
i++
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Kana and Hangul give useful script evidence; Han alone does not distinguish
|
||||
// Japanese, simplified Chinese and traditional Chinese. Half-width kana are not
|
||||
// evidence: unrelated legacy bytes frequently decode to half-width kana.
|
||||
func scriptEvidence(name, charset string) int {
|
||||
score := 0
|
||||
for _, r := range name {
|
||||
if charset == "cp932" && r < 0xff00 && (unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r)) {
|
||||
score++
|
||||
}
|
||||
if charset == "euc-kr" && unicode.Is(unicode.Hangul, r) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// DecodeNames resolves the selection once for the entire archive. Explicit
|
||||
// Unicode metadata always wins over the selected fallback encoding.
|
||||
func DecodeNames(files []*zip.File, charset string) ([]string, error) {
|
||||
if charset == "" {
|
||||
charset = Inspect(files).Suggested
|
||||
if charset == "" {
|
||||
return nil, fmt.Errorf("%w: choose an encoding before extracting", ErrEncoding)
|
||||
}
|
||||
}
|
||||
for _, c := range codecs {
|
||||
if c.name != charset {
|
||||
continue
|
||||
}
|
||||
names := make([]string, 0, len(files))
|
||||
for _, f := range files {
|
||||
name, err := decode(f, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: unsupported encoding %s", ErrEncoding, charset)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package archiveencoding
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/binary"
|
||||
"golang.org/x/text/encoding/japanese"
|
||||
"hash/crc32"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func entry(name string) *zip.File { return &zip.File{FileHeader: zip.FileHeader{Name: name}} }
|
||||
func cp932(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
s, err := japanese.ShiftJIS.NewEncoder().String(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
func TestJapaneseSuggestionMatchesDecoding(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := []string{"日本語/テストソ001.txt", "日本語/テストソ002.txt"}
|
||||
files := []*zip.File{entry(cp932(t, want[0])), entry(cp932(t, want[1]))}
|
||||
preview := Inspect(files)
|
||||
if preview.Suggested != "cp932" {
|
||||
t.Fatalf("suggestion: %+v", preview)
|
||||
}
|
||||
names, err := DecodeNames(files, preview.Suggested)
|
||||
if err != nil || !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("names=%q err=%v", names, err)
|
||||
}
|
||||
for _, c := range preview.Candidates {
|
||||
if c.Encoding == preview.Suggested && !reflect.DeepEqual(c.Names, names) {
|
||||
t.Fatalf("preview mismatch: %q", c.Names)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestAmbiguousNamesRequireSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
files := []*zip.File{entry("caf\x82.txt")}
|
||||
if p := Inspect(files); p.Suggested != "" {
|
||||
t.Fatalf("ambiguous name was guessed: %+v", p)
|
||||
}
|
||||
if _, err := DecodeNames(files, ""); err == nil {
|
||||
t.Fatal("expected explicit choice")
|
||||
}
|
||||
names, err := DecodeNames(files, "cp437")
|
||||
if err != nil || names[0] != "café.txt" {
|
||||
t.Fatalf("names=%q err=%v", names, err)
|
||||
}
|
||||
for _, c := range []string{"utf-8", "unknown"} {
|
||||
if _, err = DecodeNames(files, c); err == nil {
|
||||
t.Fatalf("invalid encoding accepted: %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
func unicodeExtra(raw, name string) []byte {
|
||||
payload := append([]byte{1, 0, 0, 0, 0}, []byte(name)...)
|
||||
binary.LittleEndian.PutUint32(payload[1:], crc32.ChecksumIEEE([]byte(raw)))
|
||||
extra := []byte{0x75, 0x70, 0, 0}
|
||||
binary.LittleEndian.PutUint16(extra[2:], uint16(len(payload)))
|
||||
return append(extra, payload...)
|
||||
}
|
||||
func TestUnicodeMetadataTakesPrecedence(t *testing.T) {
|
||||
t.Parallel()
|
||||
flagged := entry("日本語.txt")
|
||||
flagged.Flags = 0x800
|
||||
extra := entry("legacy.txt")
|
||||
extra.Extra = unicodeExtra(extra.Name, "別の名前.txt")
|
||||
names, err := DecodeNames([]*zip.File{flagged, extra}, "cp437")
|
||||
if err != nil || !reflect.DeepEqual(names, []string{"日本語.txt", "別の名前.txt"}) {
|
||||
t.Fatalf("names=%q err=%v", names, err)
|
||||
}
|
||||
extra.Extra[5] ^= 1
|
||||
names, err = DecodeNames([]*zip.File{extra}, "cp437")
|
||||
if err != nil || names[0] != "legacy.txt" {
|
||||
t.Fatalf("names=%q err=%v", names, err)
|
||||
}
|
||||
}
|
||||
func TestInvalidUnicodeMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, raw := range []string{"bad\xff.txt", "bad\x00.txt"} {
|
||||
f := entry(raw)
|
||||
f.Flags = 0x800
|
||||
if _, err := DecodeNames([]*zip.File{f}, "cp932"); err == nil {
|
||||
t.Fatalf("invalid declared UTF-8 accepted: %q", raw)
|
||||
}
|
||||
}
|
||||
f := entry("ok.txt")
|
||||
f.Extra = []byte{0x75, 0x70, 0xff, 0xff}
|
||||
if _, err := DecodeNames([]*zip.File{f}, "utf-8"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestUTF8AndASCIIArchives(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, files := range [][]*zip.File{nil, {entry("readme.txt")}, {entry("日本語.txt")}} {
|
||||
if p := Inspect(files); p.Suggested != "utf-8" {
|
||||
t.Fatalf("expected UTF-8: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestPreviewBoundAndAllNamesValidated(t *testing.T) {
|
||||
t.Parallel()
|
||||
files := make([]*zip.File, 20)
|
||||
for i := range files {
|
||||
files[i] = entry("readme.txt")
|
||||
}
|
||||
files = append(files, entry("invalid\xff.txt"))
|
||||
for _, c := range Inspect(files).Candidates {
|
||||
if len(c.Names) > 5 {
|
||||
t.Fatal("unbounded preview")
|
||||
}
|
||||
if c.Encoding == "utf-8" {
|
||||
t.Fatal("entry beyond preview was not validated")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
// Auther is the authentication interface.
|
||||
@@ -39,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,
|
||||
|
||||
@@ -14,5 +14,5 @@ type JSONAuth struct {
|
||||
|
||||
// Auth authenticates the user via a json in content body (legacy method for compatibility).
|
||||
func (auther JSONAuth) Auth(r *http.Request, _ *users.Storage) (*users.User, error) {
|
||||
return AuthenticatePassword(r, auther.ReCaptcha)
|
||||
return AuthenticatePassword(r, auther.ReCaptcha, false)
|
||||
}
|
||||
@@ -30,7 +30,8 @@ func SetDefault(s *Service) {
|
||||
}
|
||||
|
||||
// AuthenticatePassword authenticates the user via password in request headers.
|
||||
func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users.User, error) {
|
||||
// When skipRequirePasswordChange is true, a valid password is accepted even if RequirePasswordChange is set (change-required flow).
|
||||
func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool, skipRequirePasswordChange bool) (*users.User, error) {
|
||||
if s == nil || s.users == nil {
|
||||
return nil, fmt.Errorf("auth service not configured")
|
||||
}
|
||||
@@ -63,6 +64,9 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users
|
||||
if getErr != nil {
|
||||
return nil, fmt.Errorf("unable to get user from store: %v", err)
|
||||
}
|
||||
if user.RequirePasswordChange && user.LoginMethod == users.LoginMethodPassword && !skipRequirePasswordChange {
|
||||
return nil, errors.ErrPasswordChangeRequired
|
||||
}
|
||||
if user.TOTPSecret != "" && !disableOtp {
|
||||
if totpCode == "" {
|
||||
return nil, errors.ErrNoTotpProvided
|
||||
@@ -80,9 +84,9 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users
|
||||
}
|
||||
|
||||
// AuthenticatePassword authenticates via the default service.
|
||||
func AuthenticatePassword(r *http.Request, disableOtp bool) (*users.User, error) {
|
||||
func AuthenticatePassword(r *http.Request, disableOtp bool, skipRequirePasswordChange bool) (*users.User, error) {
|
||||
if defaultService != nil {
|
||||
return defaultService.AuthenticatePassword(r, disableOtp)
|
||||
return defaultService.AuthenticatePassword(r, disableOtp, skipRequirePasswordChange)
|
||||
}
|
||||
return nil, fmt.Errorf("auth service not configured")
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestSignedTokensAreUniqueAtSameTime(t *testing.T) {
|
||||
origKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "test-signing-key-for-token-uniqueness"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
|
||||
|
||||
// Freeze the clock so every token has identical issue and expiry times.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
now := time.Now()
|
||||
tokens := make(map[string]users.AuthToken)
|
||||
for _, userID := range []uint64{1, 2} {
|
||||
user := &users.User{ID: userID}
|
||||
for _, minimal := range []bool{true, false} {
|
||||
for range 2 {
|
||||
tokenString, claim, err := MakeSignedTokenAPI(user, "test-token", time.Hour, users.Permissions{Api: true}, minimal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := tokens[tokenString]; exists {
|
||||
t.Fatalf("token collision for user %d with minimal=%v", userID, minimal)
|
||||
}
|
||||
tokens[tokenString] = claim
|
||||
}
|
||||
}
|
||||
}
|
||||
ids := make(map[string]bool)
|
||||
for tokenString, claim := range tokens {
|
||||
var parsed users.MinimalAuthToken
|
||||
if _, err := jwt.ParseWithClaims(tokenString, &parsed, JWTSigningKeyFunc()); err != nil {
|
||||
t.Fatalf("parse signed token: %v", err)
|
||||
}
|
||||
if parsed.ID == "" || ids[parsed.ID] {
|
||||
t.Fatal("each signed token must have a unique nonempty id")
|
||||
}
|
||||
ids[parsed.ID] = true
|
||||
if parsed.ID != claim.ID {
|
||||
t.Fatal("signed token id must match returned metadata")
|
||||
}
|
||||
if parsed.IssuedAt == nil || !parsed.IssuedAt.Equal(now) || parsed.ExpiresAt == nil || !parsed.ExpiresAt.Equal(now.Add(time.Hour)) {
|
||||
t.Fatal("token uniqueness must not depend on changing timestamps")
|
||||
}
|
||||
assertMinimalJWTClaims(t, tokenString)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,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, ¬Null, &defaultValue, &pk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name == "created_time" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -245,6 +245,7 @@ func (db *IndexDB) CreateIndexTable() error {
|
||||
parent_path TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL DEFAULT 0,
|
||||
mod_time INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
is_dir BOOLEAN NOT NULL,
|
||||
@@ -259,17 +260,63 @@ func (db *IndexDB) CreateIndexTable() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_last_updated ON index_items(source, last_updated);
|
||||
`
|
||||
_, err := db.Exec(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.ensureCreatedTimeColumn()
|
||||
}
|
||||
|
||||
// ensureCreatedTimeColumn adds the created_time column to index_items when it is
|
||||
// missing, so databases created before the column existed are upgraded in place.
|
||||
func (db *IndexDB) ensureCreatedTimeColumn() error {
|
||||
rows, err := db.Query(`PRAGMA table_info(index_items)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid, notNull, pk int
|
||||
var name, columnType string
|
||||
var defaultValue interface{}
|
||||
scanErr := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &pk)
|
||||
if scanErr != nil {
|
||||
rows.Close()
|
||||
return scanErr
|
||||
}
|
||||
if name == "created_time" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if rowsErr := rows.Err(); rowsErr != nil {
|
||||
rows.Close()
|
||||
return rowsErr
|
||||
}
|
||||
rows.Close()
|
||||
if found {
|
||||
return nil
|
||||
}
|
||||
_, err = db.Exec(`ALTER TABLE index_items ADD COLUMN created_time INTEGER NOT NULL DEFAULT 0`)
|
||||
return err
|
||||
}
|
||||
|
||||
// createdUnix converts a creation time to unix seconds for storage, returning 0
|
||||
// when the time is nil or zero (stored as "unavailable").
|
||||
func createdUnix(created *time.Time) int64 {
|
||||
if created == nil || created.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return created.Unix()
|
||||
}
|
||||
|
||||
func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) error {
|
||||
query := `
|
||||
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO index_items (source, path, parent_path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(source, path) DO UPDATE SET
|
||||
parent_path = excluded.parent_path,
|
||||
name = excluded.name,
|
||||
size = excluded.size,
|
||||
created_time = excluded.created_time,
|
||||
mod_time = excluded.mod_time,
|
||||
type = excluded.type,
|
||||
is_dir = excluded.is_dir,
|
||||
@@ -284,6 +331,7 @@ func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) erro
|
||||
parentPath,
|
||||
info.Name,
|
||||
info.Size,
|
||||
createdUnix(info.Created),
|
||||
info.ModTime.Unix(),
|
||||
info.Type,
|
||||
info.Type == "directory",
|
||||
@@ -365,12 +413,13 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
|
||||
}()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO index_items (source, path, parent_path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(source, path) DO UPDATE SET
|
||||
parent_path = excluded.parent_path,
|
||||
name = excluded.name,
|
||||
size = excluded.size,
|
||||
created_time = excluded.created_time,
|
||||
mod_time = excluded.mod_time,
|
||||
type = excluded.type,
|
||||
is_dir = excluded.is_dir,
|
||||
@@ -396,6 +445,7 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
|
||||
parentPath,
|
||||
info.Name,
|
||||
info.Size,
|
||||
createdUnix(info.Created),
|
||||
info.ModTime.Unix(),
|
||||
info.Type,
|
||||
info.Type == "directory",
|
||||
@@ -428,7 +478,7 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
|
||||
// Busy/locked errors are returned so callers can fall back to the filesystem.
|
||||
func (db *IndexDB) GetItem(source, path string) (*iteminfo.FileInfo, error) {
|
||||
query := `
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items WHERE source = ? AND path = ?
|
||||
`
|
||||
row := db.QueryRow(query, source, path)
|
||||
@@ -456,7 +506,7 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items WHERE source = ? AND path IN (%s)
|
||||
`, strings.Join(placeholders, ","))
|
||||
|
||||
@@ -481,7 +531,7 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
|
||||
// GetDirectoryChildren retrieves all children of a directory for a specific source.
|
||||
func (db *IndexDB) GetDirectoryChildren(source, dirPath string) ([]*iteminfo.FileInfo, error) {
|
||||
query := `
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items WHERE source = ? AND parent_path = ?
|
||||
ORDER BY is_dir DESC, name ASC
|
||||
`
|
||||
@@ -927,7 +977,7 @@ func (db *IndexDB) GetFilesForMultipleSizes(source string, sizes []int64, pathPr
|
||||
if pathPrefix != "" {
|
||||
nextPrefix := getNextPathPrefix(pathPrefix)
|
||||
query = fmt.Sprintf(`
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ? AND size IN (%s) AND is_dir = 0 AND path >= ? AND path < ?
|
||||
ORDER BY size, type, name
|
||||
@@ -935,7 +985,7 @@ func (db *IndexDB) GetFilesForMultipleSizes(source string, sizes []int64, pathPr
|
||||
args = append(args, pathPrefix, nextPrefix)
|
||||
} else {
|
||||
query = fmt.Sprintf(`
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ? AND size IN (%s) AND is_dir = 0
|
||||
ORDER BY size, type, name
|
||||
@@ -1095,7 +1145,7 @@ func (db *IndexDB) GetFilesBySizeAndType(source string, size int64, fileType str
|
||||
if pathPrefix != "" {
|
||||
nextPrefix := getNextPathPrefix(pathPrefix)
|
||||
query = `
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ? AND size = ? AND type = ? AND is_dir = 0 AND path >= ? AND path < ?
|
||||
ORDER BY name
|
||||
@@ -1103,7 +1153,7 @@ func (db *IndexDB) GetFilesBySizeAndType(source string, size int64, fileType str
|
||||
args = []interface{}{source, size, fileType, pathPrefix, nextPrefix}
|
||||
} else {
|
||||
query = `
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ? AND size = ? AND type = ? AND is_dir = 0
|
||||
ORDER BY name
|
||||
@@ -1137,7 +1187,7 @@ func (db *IndexDB) GetFilesBySize(source string, size int64, pathPrefix string)
|
||||
if pathPrefix != "" {
|
||||
nextPrefix := getNextPathPrefix(pathPrefix)
|
||||
query = `
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ? AND size = ? AND is_dir = 0 AND path >= ? AND path < ?
|
||||
ORDER BY name
|
||||
@@ -1145,7 +1195,7 @@ func (db *IndexDB) GetFilesBySize(source string, size int64, pathPrefix string)
|
||||
args = []interface{}{source, size, pathPrefix, nextPrefix}
|
||||
} else {
|
||||
query = `
|
||||
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
|
||||
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ? AND size = ? AND is_dir = 0
|
||||
ORDER BY name
|
||||
@@ -1260,6 +1310,7 @@ func getParentPath(path string) string {
|
||||
|
||||
func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileInfo, error) {
|
||||
var info iteminfo.FileInfo
|
||||
var createdTime int64
|
||||
var modTime int64
|
||||
var isDir bool
|
||||
|
||||
@@ -1267,6 +1318,7 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
|
||||
&info.Path,
|
||||
&info.Name,
|
||||
&info.Size,
|
||||
&createdTime,
|
||||
&modTime,
|
||||
&info.Type,
|
||||
&isDir,
|
||||
@@ -1276,6 +1328,10 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if createdTime > 0 {
|
||||
t := time.Unix(createdTime, 0)
|
||||
info.Created = &t
|
||||
}
|
||||
info.ModTime = time.Unix(modTime, 0)
|
||||
info.IsDir = isDir
|
||||
return &info, nil
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -2,6 +2,7 @@ package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
@@ -102,6 +103,46 @@ func SyncUserGroups(username string, newGroups []string) error {
|
||||
return accessDb.SyncUserGroups(username, newGroups)
|
||||
}
|
||||
|
||||
// GroupExists reports whether a group name is already registered.
|
||||
func GroupExists(groupname string) bool {
|
||||
if accessDb == nil {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(accessDb.GetAllGroups(), groupname)
|
||||
}
|
||||
|
||||
// RemoveUserFromAllGroups removes a username from every group's member list.
|
||||
func RemoveUserFromAllGroups(username string) error {
|
||||
if accessDb == nil {
|
||||
return nil
|
||||
}
|
||||
return accessDb.RemoveUserFromAllGroups(username)
|
||||
}
|
||||
|
||||
// RenameUserInGroups replaces one username with another in all group member lists.
|
||||
func RenameUserInGroups(oldName, newName string) error {
|
||||
if accessDb == nil {
|
||||
return nil
|
||||
}
|
||||
return accessDb.RenameUserInGroups(oldName, newName)
|
||||
}
|
||||
|
||||
// RenameUserInRules replaces one username with another in all path access allow/deny lists.
|
||||
func RenameUserInRules(oldName, newName string) error {
|
||||
if accessDb == nil {
|
||||
return nil
|
||||
}
|
||||
return accessDb.RenameUserInRules(oldName, newName)
|
||||
}
|
||||
|
||||
// RemoveAllRulesForUser removes a username from every path access allow and deny list.
|
||||
func RemoveAllRulesForUser(username string) error {
|
||||
if accessDb == nil {
|
||||
return nil
|
||||
}
|
||||
return accessDb.RemoveAllRulesForUser(username)
|
||||
}
|
||||
|
||||
// SetAccessSQLStoreForTest replaces the SQL persister used by access control (tests only).
|
||||
func SetAccessSQLStoreForTest(store access.SQLPersister) {
|
||||
if accessDb != nil {
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
const bootstrapAdminPasswordHexBytes = 6 // 12 hex characters
|
||||
|
||||
// bootstrapDefaultAdminPassword returns the plaintext password for initial admin creation.
|
||||
// Uses auth.methods.password.adminPassword (backfilled from auth.adminPassword when unset).
|
||||
// Blank or "admin" is ignored and a random password is generated and logged once.
|
||||
// When config adminPassword is blank or "admin", a random speakable password is generated and logged once.
|
||||
func bootstrapDefaultAdminPassword(username string) (plaintext string, generated bool, err error) {
|
||||
cfg := settings.PasswordAdminPassword()
|
||||
if cfg != "" && cfg != "admin" {
|
||||
return cfg, false, nil
|
||||
}
|
||||
|
||||
plaintext, err = utils.RandomHex(bootstrapAdminPasswordHexBytes)
|
||||
plaintext, err = generateSpeakableBootstrapPassword()
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("generate bootstrap admin password: %w", err)
|
||||
}
|
||||
@@ -30,3 +29,37 @@ func bootstrapDefaultAdminPassword(username string) (plaintext string, generated
|
||||
)
|
||||
return plaintext, true, nil
|
||||
}
|
||||
|
||||
func generateSpeakableBootstrapPassword() (string, error) {
|
||||
if len(bootstrapWords) == 0 {
|
||||
return "", fmt.Errorf("bootstrap word list is empty")
|
||||
}
|
||||
wordIdx, err := rand.Int(rand.Reader, big.NewInt(int64(len(bootstrapWords))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
word := bootstrapWords[wordIdx.Int64()]
|
||||
|
||||
primary, err := randomSpeakableCode(bootstrapSpeakablePrimaryCodeLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secondary, err := randomSpeakableCode(bootstrapSpeakableSecondaryCodeLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s-%s-%s", word, string(primary), string(secondary)), nil
|
||||
}
|
||||
|
||||
func randomSpeakableCode(n int) ([]byte, error) {
|
||||
codeRunes := make([]byte, n)
|
||||
charsetLen := big.NewInt(int64(len(bootstrapSpeakableCharset)))
|
||||
for i := range codeRunes {
|
||||
idx, err := rand.Int(rand.Reader, charsetLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
codeRunes[i] = bootstrapSpeakableCharset[idx.Int64()]
|
||||
}
|
||||
return codeRunes, nil
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestBootstrapWordsCount(t *testing.T) {
|
||||
if len(bootstrapWords) != 100 {
|
||||
t.Fatalf("bootstrapWords len = %d, want 100", len(bootstrapWords))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapDefaultAdminPasswordUsesNestedConfigWhenSet(t *testing.T) {
|
||||
origNested := settings.Config.Auth.Methods.PasswordAuth.AdminPassword
|
||||
origLegacy := settings.Config.Auth.AdminPassword
|
||||
@@ -51,6 +58,8 @@ func TestBootstrapDefaultAdminPasswordUsesTopLevelWhenNestedUnset(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
var speakableBootstrapPasswordPattern = regexp.MustCompile(`^[a-z]+-[abcdefghjkmnpqrstuvwxyz23456789]{5}-[abcdefghjkmnpqrstuvwxyz23456789]{2}$`)
|
||||
|
||||
func TestBootstrapDefaultAdminPasswordGeneratesWhenDefault(t *testing.T) {
|
||||
origNested := settings.Config.Auth.Methods.PasswordAuth.AdminPassword
|
||||
origLegacy := settings.Config.Auth.AdminPassword
|
||||
@@ -72,11 +81,31 @@ func TestBootstrapDefaultAdminPasswordGeneratesWhenDefault(t *testing.T) {
|
||||
if plain == "" || plain == "admin" {
|
||||
t.Fatalf("cfg=%q: unexpected password %q", cfg, plain)
|
||||
}
|
||||
if len(plain) != bootstrapAdminPasswordHexBytes*2 {
|
||||
t.Fatalf("cfg=%q: password length %d, want %d", cfg, len(plain), bootstrapAdminPasswordHexBytes*2)
|
||||
if !speakableBootstrapPasswordPattern.MatchString(plain) {
|
||||
t.Fatalf("cfg=%q: password %q does not match speakable pattern", cfg, plain)
|
||||
}
|
||||
if strings.Trim(plain, "0123456789abcdef") != "" {
|
||||
t.Fatalf("cfg=%q: expected hex password, got %q", cfg, plain)
|
||||
word := strings.Split(plain, "-")[0]
|
||||
found := false
|
||||
for _, w := range bootstrapWords {
|
||||
if w == word {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("cfg=%q: word %q not in bootstrap word list", cfg, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSpeakableBootstrapPassword(t *testing.T) {
|
||||
for i := 0; i < 20; i++ {
|
||||
plain, err := generateSpeakableBootstrapPassword()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !speakableBootstrapPasswordPattern.MatchString(plain) {
|
||||
t.Fatalf("password %q does not match speakable pattern", plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package state
|
||||
|
||||
// bootstrapWords is a fixed list of short, common English nouns for one-time bootstrap passwords.
|
||||
// Words are chosen to be easy to say, spell, and type (including for non-native English speakers).
|
||||
var bootstrapWords = []string{
|
||||
"apple", "ball", "barn", "bean", "bed", "belt", "bike", "bird", "boat", "book",
|
||||
"boot", "bowl", "box", "bus", "cake", "camp", "cart", "cat", "clock", "cloud",
|
||||
"coat", "coin", "corn", "cup", "desk", "dog", "door", "duck", "egg", "farm",
|
||||
"fish", "flag", "food", "foot", "game", "gift", "goat", "gold", "grass", "hand",
|
||||
"hat", "hill", "home", "horse", "house", "ice", "jar", "key", "kid", "lake",
|
||||
"lamp", "land", "leaf", "leg", "light", "line", "lion", "list", "mail", "map",
|
||||
"milk", "moon", "mouse", "nest", "net", "night", "nose", "note", "page", "park",
|
||||
"path", "pen", "pet", "pie", "pig", "pin", "pipe", "plan", "plant", "pot",
|
||||
"rain", "red", "ring", "road", "rock", "room", "rope", "rose", "sand", "sea",
|
||||
"ship", "shoe", "shop", "sky", "snow", "sock", "star", "stone", "sun", "tree",
|
||||
}
|
||||
|
||||
// bootstrapSpeakableCharset excludes ambiguous characters for read-aloud (no 0/o, 1/i/l).
|
||||
const bootstrapSpeakableCharset = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
|
||||
// bootstrapSpeakablePrimaryCodeLen and bootstrapSpeakableSecondaryCodeLen form the code after the word (e.g. word-xxxxx-xx).
|
||||
const bootstrapSpeakablePrimaryCodeLen = 5
|
||||
const bootstrapSpeakableSecondaryCodeLen = 2
|
||||
@@ -52,10 +52,13 @@ func QuickSetup() error {
|
||||
|
||||
logger.Debugf("Creating user as admin: %v", user.Username)
|
||||
|
||||
plainPassword, _, err := bootstrapDefaultAdminPassword(user.Username)
|
||||
plainPassword, generated, err := bootstrapDefaultAdminPassword(user.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if generated {
|
||||
user.RequirePasswordChange = true
|
||||
}
|
||||
|
||||
hashedPassword, hashErr := utils.HashPwd(plainPassword)
|
||||
if hashErr != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
Reference in new issue
Block a user