Compare commits

...
19 Commits
Author SHA1 Message Date
Graham Steffaniak 3956ccd859 make sidebar tree view links native hyperlinks (#3080) 2026-10-04 16:19:09 -05:00
Graham Steffaniak 3f756de845 port fix over to dev/v2.0.10 2026-10-04 15:58:39 -05:00
Graham Steffaniak aa98563db7 Merge branch 'dev/v2.0.10' of github.com:gtsteffaniak/filebrowser into dev/v2.0.10 2026-10-04 15:57:57 -05:00
Graham Steffaniak fb7f25b0a8 Fix bugs halloween prompts (#3076) 2026-10-04 15:38:16 -05:00
Graham Steffaniak 7f56deb03b Merge branch 'dev/v2.0.10' of github.com:gtsteffaniak/filebrowser into dev/v2.0.10 2026-10-04 12:27:11 -05:00
Graham Steffaniak 592d8db506 Update version match workflow (#3072) 2026-10-04 12:26:59 -05:00
Graham Steffaniak 0b346bd8c7 Merge branch 'dev/v2.0.10' of github.com:gtsteffaniak/filebrowser into dev/v2.0.10 2026-10-04 12:04:49 -05:00
Graham Steffaniak 8f4e148cf3 updated cli setup command (#3071) 2026-10-04 12:04:37 -05:00
Graham Steffaniak 82ab5957dd updated cli setup command 2026-10-04 11:50:18 -05:00
Graham Steffaniak 033e1a4642 upgrade docker 2026-10-04 11:33:30 -05:00
Graham SteffaniakandCursor 78cb4ca214 Fix GHSA-6gr6-5qpq-888p: API token session renew (#3070)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 11:29:14 -05:00
Graham SteffaniakandCursor b40be3e25e Fix TOTP re-enrollment MFA bypass (GHSA-qx86-4v5r-26g5). (#3067)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 10:49:36 -05:00
Graham Steffaniak e4fc79287d added feature to reset user password on login (#3064) 2026-10-03 20:00:01 -05:00
Graham SteffaniakandCursor 9ae8cc07e6 fix: GHSA-p7x3-p5jj-9xfh - Enforce public share limits on lyrics and subtitle routes. (#3061)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:20:01 -05:00
Graham Steffaniak b2936e5c80 Fix token expiration (#3056) 2026-10-03 16:54:11 -05:00
Kevin B. f501c765a6 fix sidebar behind header on iOS (#3060) 2026-10-03 16:18:01 -05:00
Graham SteffaniakandDevin b861660ce9 Fix inflated disk usage on ZFS datasets and btrfs subvolumes; add roo… (#3035)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-02 16:16:31 -05:00
Graham Steffaniak 98c96c3ec4 Case insensitive match ldap (#3048) 2026-10-01 22:05:58 -05:00
Graham Steffaniak b7e06fd333 added support for env vars in config (#3047) 2026-09-30 17:53:01 -05:00
130 changed files with 5053 additions and 1210 deletions

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_REPO="${IMAGE_REPO:-gtstef/filebrowser}"
SLIM_SUFFIX="${SLIM_SUFFIX:-}"
if [[ -z "${GITHUB_OUTPUT:-}" ]]; then
echo "GITHUB_OUTPUT is not set" >&2
exit 1
fi
write_outputs() {
local update_track="$1"
local update_major="$2"
echo "update_track=${update_track}" >> "$GITHUB_OUTPUT"
echo "update_major=${update_major}" >> "$GITHUB_OUTPUT"
}
registry_version() {
local tag="$1"
local ver err err_file
err_file="$(mktemp)"
if ! ver="$(docker buildx imagetools inspect "${IMAGE_REPO}:${tag}" \
--format '{{ with index .Image "linux/amd64" }}{{ index .Config.Labels "org.opencontainers.image.version" }}{{ end }}' 2>"$err_file")"; then
err="$(cat "$err_file")"
rm -f "$err_file"
if grep -qiE 'not found|manifest unknown' <<<"$err"; then
ver=""
else
echo "Failed to inspect ${IMAGE_REPO}:${tag}: ${err}" >&2
exit 1
fi
else
rm -f "$err_file"
if [[ -z "$ver" ]]; then
echo "Missing org.opencontainers.image.version on ${IMAGE_REPO}:${tag} (linux/amd64)" >&2
exit 1
fi
fi
ver="${ver#v}"
printf '%s' "$ver"
}
semver_allows_update() {
local new="$1"
local incumbent="$2"
if [[ -z "$incumbent" ]]; then
return 0
fi
local winner
winner="$(printf '%s\n' "$incumbent" "$new" | sort -V | tail -n1)"
[[ "$winner" == "$new" ]]
}
ref="${GITHUB_REF_NAME:-}"
ref="${ref#v}"
if [[ ! "$ref" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then
echo "Tag '${GITHUB_REF_NAME:-}' has no X.Y.Z-suffix form; leaving major-only floats enabled, track floats disabled"
write_outputs false true
exit 0
fi
new_version="$ref"
semver_core="${BASH_REMATCH[1]}"
suffix="${BASH_REMATCH[2]}"
major="$(echo "$semver_core" | cut -d. -f1)"
track=""
case "$suffix" in
beta) track="beta" ;;
stable) track="stable" ;;
*)
echo "Tag suffix '${suffix}' is not beta/stable; leaving major-only floats enabled, track floats disabled"
write_outputs false true
exit 0
;;
esac
track_tag="${track}${SLIM_SUFFIX}"
major_tag="${major}-${track}${SLIM_SUFFIX}"
incumbent_track="$(registry_version "$track_tag")"
incumbent_major="$(registry_version "$major_tag")"
update_track=false
if semver_allows_update "$new_version" "$incumbent_track"; then
update_track=true
fi
update_major=false
if semver_allows_update "$new_version" "$incumbent_major"; then
update_major=true
fi
echo "Floating tag policy (${SLIM_SUFFIX:-full} image):"
echo " New version: ${new_version}"
echo " Track tag: ${track_tag} (incumbent: ${incumbent_track:-<none>}) -> update_track=${update_track}"
echo " Major tag: ${major_tag} (incumbent: ${incumbent_major:-<none>}) -> update_major=${update_major}"
write_outputs "$update_track" "$update_major"
+34 -18
View File
@@ -61,18 +61,23 @@ jobs:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Decide floating Docker tags
id: floating
run: bash .github/scripts/docker-floating-tag-policy.sh
- name: Modify tags (strip 'v' and add version tags)
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
MODIFIED_JSON=$(echo "$JSON" | jq \
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
.tags |= map(
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not)
) |
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor and major tags
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor (always) and major tags (when allowed)
.tags += [
.tags[]
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
@@ -83,18 +88,20 @@ jobs:
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
if $updateMajor then
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
else empty end
] |
# Add suffix-only tags for beta (only if tag ends with -beta)
if any(.tags[]; split(":")[-1] | endswith("-beta")) then
# Add suffix-only tags for beta when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta"
]
else . end |
# Add suffix-only tags for stable (and latest) (only if tag ends with -stable)
if any(.tags[]; split(":")[-1] | endswith("-stable")) then
# Add suffix-only tags for stable (and latest) when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable")) then
.tags += [
"gtstef/filebrowser:stable",
"ghcr.io/gtsteffaniak/filebrowser:stable",
@@ -146,19 +153,26 @@ jobs:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Decide floating Docker tags
id: floating
env:
SLIM_SUFFIX: -slim
run: bash .github/scripts/docker-floating-tag-policy.sh
- name: Modify tags (strip 'v' and add 'slim' if needed)
id: modify-json-slim
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
MODIFIED_JSON=$(echo "$JSON" | jq \
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
.tags |= map(
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not) # Remove the "latest" tag
| . + "-slim" # Append "-slim" to the versioned tag
) |
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor and major tags
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor (always) and major tags (when allowed)
.tags += [
.tags[]
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
@@ -169,18 +183,20 @@ jobs:
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
if $updateMajor then
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
else empty end
] |
# Add suffix-only tags for beta-slim (only if tag ends with -beta-slim)
if any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
# Add suffix-only tags for beta-slim when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
.tags += [
"gtstef/filebrowser:beta-slim",
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
]
else . end |
# Add suffix-only tags for stable-slim (only if tag ends with -stable-slim)
if any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
# Add suffix-only tags for stable-slim when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
.tags += [
"gtstef/filebrowser:stable-slim",
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
+25
View File
@@ -2,6 +2,31 @@
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.0.10
**Security**:
- [High] Restricted custom API tokens (`minimal=false`) can no longer be upgraded to a full-permission session via `POST /api/auth/renew`; renew accepts only web session tokens registered in `hashed_tokens` (GHSA-6gr6-5qpq-888p) -- thanks @tao0845.
- [High] TOTP re-enrollment via `POST /api/auth/otp/generate` and `POST /api/auth/otp/verify` no longer allows anonymous callers to replace an existing second factor using only the account password; reset or replace requires an authenticated self or admin session (first-time enrollment without MFA unchanged) (GHSA-qx86-4v5r-26g5) -- thanks @tao0845.
- [Low] Public share lyrics and subtitle media routes now honor the share's file-viewer setting, download disable flag, and download limits, matching the public download route (GHSA-p7x3-p5jj-9xfh) -- thanks Yves Soete of Blacksight LLC. @yssoe
**New Features**:
- Require password change at next login for password-based users: new user setting `requirePasswordChange` (user defaults + per-user admin toggle), login blocked until the user sets a new password; bootstrap admins with a generated initial password get this automatically ([#2977](https://github.com/gtsteffaniak/filebrowser/issues/2977)). Generated bootstrap passwords use a speakable `word-xxxxx-xx` form (random word from a fixed list plus a 5-character and 2-character code) instead of a long hex string, for easier handoff before the forced change.
- Config YAML expands `$VAR` and `${VAR}` so values such as `userPassword: "${FILEBROWSER_LDAP_USER_PASSWORD}"` work as documented (#3042).
**Notes**:
- Sidebar navigation tree rows are real hyperlinks: middle-click, Ctrl/Cmd+click, and Shift+click use the browser’s default new-tab or new-window behavior.
- Sidebar source links can now switch between aggregated usage (default) and a root-filesystem-only view via a new "Limit disk usage to source filesystem" toggle.
- [docker] upgraded ffmpeg from 9.0 to 9.0.2
- CLI setup command no longer produces full config, instead a minimal config.yaml with comments.
**Bugfixes**:
- Fixed slow or broken file listing when `http.baseURL` is a subpath (for example `/files/`): the default source redirect navigated to `/files/{source}` on top of the app base URL, producing `/files/files/{source}` and resolving the wrong storage source until the route recovered.
- Anonymous visitors on public shares could not play inline video or audio: `GET /public/api/media/stream` returned 403 because view grants were validated before share context was fully hydrated; playback now works when download and the file viewer are allowed ([#3041](https://github.com/gtsteffaniak/filebrowser/issues/3041)).
- Fixed inflated disk usage for sources spanning multiple ZFS datasets (or btrfs subvolumes): shared-pool filesystems are now grouped by pool and counted once, instead of multiplying capacity by the number of mounted datasets (#3025) (#2997) (#2761).
- LDAP `userGroups` matching now accepts CN-only config values against full `memberOf` DNs and compares case-insensitively, instead of failing the shared auth helper with strict string equality (#3044).
- OIDC session expires despite tokenExpirationHours (#3006).
- Media playback becomes stuck after opening a failing media file since v2.0.8-beta (#3031)
## v2.0.9
**Security**:
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:9.0-decode AS ffmpeg
FROM ghcr.io/gtsteffaniak/ffmpeg:9.0.2-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
@@ -1,6 +1,9 @@
http:
port: 8080
baseURL: "/files/"
frontend:
styling:
disableEventThemes: true
auth:
adminPassword: "playwright-password"
+38 -20
View File
@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
@@ -225,19 +224,11 @@ func createConfig(configpath string, noInput bool) error {
fmt.Printf("Error: '%s' is not a valid port. Please enter a number between 1 and 65535.\n", portStr)
}
for {
levels := askQuestion(reader, "What should the log levels be?", "info|warning|error")
checkLevels := SplitByMultiple(levels)
invalidOptions := []string{}
for _, level := range checkLevels {
if !(level == "info" || level == "warning" || level == "error" || level == "debug") {
invalidOptions = append(invalidOptions, level)
}
}
if len(invalidOptions) == 0 {
break
}
fmt.Printf("Error: invalid options given '%s'. valid options: 'info|warning|error|debug'.\n", invalidOptions)
config.Server.Logging = []settings.LogConfig{
{
Output: "stdout",
Levels: "info|warning|error",
},
}
for {
@@ -250,9 +241,20 @@ func createConfig(configpath string, noInput bool) error {
}
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
}
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
config.Auth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
config.Auth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
const defaultFrontendName = "FileBrowser Quantum"
brandName := askQuestion(reader, "What should the application brand name be?", defaultFrontendName)
if brandName != defaultFrontendName {
config.Frontend.Name = brandName
}
adminUsername := askQuestion(reader, "What should the default admin username be?", "admin")
if adminUsername != "admin" {
config.Auth.AdminUsername = adminUsername
}
const adminPasswordGenerateDefault = "generate on startup"
adminPassword := askQuestion(reader, "What should the default admin password be?", adminPasswordGenerateDefault)
if adminPassword != "" && !strings.EqualFold(adminPassword, adminPasswordGenerateDefault) {
config.Auth.AdminPassword = adminPassword
}
modifyDefault := askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
config.Server.Sources[0].Config.DefaultPermissions = settings.NormalizeSourceFilePermissions(users.SourceFilePermissions{
@@ -266,12 +268,12 @@ func createConfig(configpath string, noInput bool) error {
fmt.Println("--- Configuration Complete ---")
yamlData, err := yaml.Marshal(&config)
yamlData, err := generateSetupConfigYAML(&config)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
return fmt.Errorf("generating config YAML: %w", err)
}
err = os.WriteFile(configpath, yamlData, 0600)
err = os.WriteFile(configpath, []byte(yamlData), 0600)
if err != nil {
return fmt.Errorf("writing config file: %w", err)
}
@@ -291,6 +293,22 @@ func createConfig(configpath string, noInput bool) error {
return nil
}
func generateSetupConfigYAML(config *settings.Settings) (string, error) {
embeddedPaths := []string{
"frontend/public/config.generated.yaml",
"../frontend/public/config.generated.yaml",
"internal/web/dist/config.generated.yaml",
"../internal/web/dist/config.generated.yaml",
}
for _, path := range embeddedPaths {
embeddedYaml, err := os.ReadFile(path)
if err == nil && len(embeddedYaml) > 0 {
return settings.GenerateConfigYamlWithEmbedded(config, true, false, false, string(embeddedYaml))
}
}
return settings.GenerateConfigYaml(config, true, false, false)
}
func generateYaml() {
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
if generateConfig || settings.Env.IsDevMode {
+24
View File
@@ -9,6 +9,7 @@ import (
"testing"
"github.com/alecthomas/kong"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -191,3 +192,26 @@ func TestSetupNoInputRejected(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "interactive input")
}
func TestGenerateSetupConfigYAML_MinimalWithComments(t *testing.T) {
t.Chdir("..")
cfg := settings.SetDefaults(false)
cfg.Server.Sources = []*settings.Source{{Path: "/var/lib/files"}}
cfg.Server.DatabaseV2.Path = "./filebrowser.sqlite"
cfg.Http.Port = 8080
cfg.Server.Logging = []settings.LogConfig{
{Output: "stdout", Levels: "info|warning|error"},
}
out, err := generateSetupConfigYAML(&cfg)
require.NoError(t, err)
assert.Contains(t, out, "#")
assert.Contains(t, out, "/var/lib/files")
assert.Contains(t, out, "8080")
assert.NotContains(t, out, "FileBrowser Quantum")
assert.NotContains(t, out, "darkMode")
assert.NotContains(t, out, "minSearchLength:")
assert.NotContains(t, out, "frontend:")
assert.NotContains(t, out, "rules:")
}
@@ -1,6 +1,9 @@
http:
port: 8080
baseURL: "/"
frontend:
styling:
disableEventThemes: true
server:
logging:
- levels: "info|warning|error"
+1 -1
View File
@@ -53,7 +53,7 @@ func TestUserUpdateChangesFiltersUnchangedFields(t *testing.T) {
which := []string{
"preview", "stickySidebar", "darkMode", "locale", "singleClick", "sorting",
"showHidden", "dateFormat", "themeColor", "quickDownload", "fileLoading",
"sidebarLinks", "deleteAfterArchive", "preferEditorForMarkdown", "showFirstLogin",
"sidebarLinks", "deleteAfterArchive", "preferEditorForMarkdown", "showFirstLogin", "requirePasswordChange",
}
changes := UserUpdateChanges(before, &after, which, false)
@@ -0,0 +1,30 @@
package fileutils
// PartitionUsage is a capacity view of a source path: total and used bytes.
type PartitionUsage struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
}
// singlePathPartitionUsage statfs's a single path: the filesystem covering it.
func singlePathPartitionUsage(path string) (PartitionUsage, error) {
total, err := GetPartitionSize(path)
if err != nil {
return PartitionUsage{}, err
}
used, err := GetPartitionUsed(path)
if err != nil {
return PartitionUsage{}, err
}
return PartitionUsage{Total: total, Used: used}, nil
}
// GetAggregatedPartitionUsage returns total and used bytes summed across distinct
// capacity groups mounted at or under root. See GetPartitionUsageVariants.
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
aggregate, _, err := GetPartitionUsageVariants(root)
if err != nil {
return 0, 0, err
}
return aggregate.Total, aggregate.Used, nil
}
@@ -0,0 +1,188 @@
//go:build freebsd
package fileutils
import (
"fmt"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
// bsdMount is one Getfsstat record reduced to what capacity aggregation
// needs: fstype, source, mountpoint, usage, and the mount's own fsid.
type bsdMount struct {
fstype string
source string
mountpoint string
usage rawUsage
fsid unix.Fsid
}
// resolvedMount is what a path lookup reports for a mountpoint: the
// mountpoint of the filesystem that actually owns the path and that
// filesystem's fsid.
type resolvedMount struct {
mountpoint string
fsid unix.Fsid
ok bool
}
// statfsMountpoint resolves which filesystem a mountpoint path lands on.
func statfsMountpoint(mp string) resolvedMount {
var st unix.Statfs_t
if err := unix.Statfs(mp, &st); err != nil {
return resolvedMount{}
}
return resolvedMount{
mountpoint: filepath.Clean(unix.ByteSliceToString(st.Mntonname[:])),
fsid: st.Fsid,
ok: true,
}
}
// GetPartitionUsageVariants returns the aggregate and root-only capacity views
// of root. FreeBSD has no mountinfo; Getfsstat already carries per-mount statfs
// data, so grouping happens on the result set directly (ZFS datasets grouped by
// pool name, everything else by fsid).
func GetPartitionUsageVariants(root string) (aggregate, rootOnly PartitionUsage, err error) {
if root == "" {
return aggregate, rootOnly, fmt.Errorf("empty path")
}
root = filepath.Clean(root)
rootOnly, rootErr := singlePathPartitionUsage(root)
count, statErr := unix.Getfsstat(nil, unix.MNT_WAIT)
if statErr != nil || count <= 0 {
if rootErr != nil {
return aggregate, rootOnly, rootErr
}
return rootOnly, rootOnly, nil
}
stats := make([]unix.Statfs_t, count)
if _, statErr = unix.Getfsstat(stats, unix.MNT_WAIT); statErr != nil {
if rootErr != nil {
return aggregate, rootOnly, rootErr
}
return rootOnly, rootOnly, nil
}
var mounts []bsdMount
for i := range stats {
s := &stats[i]
avail := uint64(0)
if s.Bavail > 0 {
avail = uint64(s.Bavail)
}
mounts = append(mounts, bsdMount{
fstype: unix.ByteSliceToString(s.Fstypename[:]),
source: unix.ByteSliceToString(s.Mntfromname[:]),
mountpoint: filepath.Clean(unix.ByteSliceToString(s.Mntonname[:])),
usage: rawUsage{
total: s.Blocks * s.Bsize,
avail: avail * s.Bsize,
},
fsid: s.Fsid,
})
}
covering, nested := selectCapacityMounts(mounts, root, statfsMountpoint)
key := func(m bsdMount) string {
if isZFSType(m.fstype) {
return "zfs:" + zfsPoolName(m.source)
}
if m.fsid.Val[0] == 0 && m.fsid.Val[1] == 0 {
// fsid-less filesystems (e.g. some tmpfs mounts): keep per mountpoint.
return "mp:" + m.mountpoint
}
return fmt.Sprintf("fsid:%x:%x", m.fsid.Val[0], m.fsid.Val[1])
}
groups := make(map[string][]rawUsage)
fstypes := make(map[string]string)
add := func(m bsdMount) {
k := key(m)
fstypes[k] = m.fstype
groups[k] = append(groups[k], m.usage)
}
if covering != nil {
add(*covering)
}
for _, m := range nested {
add(m)
}
if len(groups) == 0 {
if rootErr != nil {
return aggregate, rootOnly, rootErr
}
return rootOnly, rootOnly, nil
}
for k, stats := range groups {
u := combineGroupUsage(stats)
if !isZFSType(fstypes[k]) && len(stats) > 1 {
// Non-shared-pool mounts dedupe to a single measurement.
u = combineGroupUsage(stats[:1])
}
aggregate.Total += u.Total
aggregate.Used += u.Used
}
return aggregate, rootOnly, rootErr
}
// selectCapacityMounts picks the mounts contributing capacity under root: the
// filesystem covering root plus mounts nested beneath it.
//
// Getfsstat retains records that path lookups no longer reach: mounts stacked
// under a later mount at the same mountpoint, and mounts below an overmounted
// ancestor (stacked records have no documented order, so position cannot
// identify the visible one). A record counts only when a lookup on its
// mountpoint still lands on it: the resolved mountpoint must equal the
// record's and, for filesystems carrying an fsid, the resolved fsid must match
// the record's (every stacked record resolves to the topmost fsid). fsid-less
// records keep the first match at a mountpoint; stacked fsid-less mounts are
// indistinguishable.
func selectCapacityMounts(mounts []bsdMount, root string, lookup func(string) resolvedMount) (*bsdMount, []bsdMount) {
var covering *bsdMount
var nested []bsdMount
claimed := make(map[string]bool)
resolved := make(map[string]resolvedMount)
for i := range mounts {
m := mounts[i]
covers := m.mountpoint == root || m.mountpoint == "/" || strings.HasPrefix(root, m.mountpoint+"/")
nests := strings.HasPrefix(m.mountpoint, root+"/")
if !covers && !nests {
continue
}
r, seen := resolved[m.mountpoint]
if !seen {
r = lookup(m.mountpoint)
resolved[m.mountpoint] = r
}
if !r.ok || r.mountpoint != m.mountpoint {
continue
}
if m.fsid.Val[0] != 0 || m.fsid.Val[1] != 0 {
if r.fsid != m.fsid {
continue
}
} else if claimed[m.mountpoint] {
continue
}
claimed[m.mountpoint] = true
if covers && (covering == nil || len(m.mountpoint) > len(covering.mountpoint)) {
c := m
covering = &c
}
if nests {
if isZFSType(m.fstype) && isZFSSnapshotMount(m.source, m.mountpoint) {
continue
}
nested = append(nested, m)
}
}
return covering, nested
}
@@ -0,0 +1,161 @@
//go:build freebsd
package fileutils
import (
"testing"
"golang.org/x/sys/unix"
)
func fsidVal(a, b int32) unix.Fsid {
return unix.Fsid{Val: [2]int32{a, b}}
}
func resolvedOK(mp string, fsid unix.Fsid) resolvedMount {
return resolvedMount{mountpoint: mp, fsid: fsid, ok: true}
}
// TestSelectCapacityMountsOvermountedAncestorHidesNested reproduces the
// reviewed layout: /srv/child was mounted first, then /srv was overmounted.
// Getfsstat still reports the unique /srv/child record, but a lookup of that
// path lands inside the covering filesystem, so its usage must be excluded.
func TestSelectCapacityMountsOvermountedAncestorHidesNested(t *testing.T) {
rootFs, childFs, coverFs := fsidVal(1, 1), fsidVal(2, 2), fsidVal(3, 3)
mounts := []bsdMount{
{fstype: "ufs", source: "/dev/da0p2", mountpoint: "/", fsid: rootFs, usage: rawUsage{total: 100, avail: 50}},
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/srv/child", fsid: childFs, usage: rawUsage{total: 200, avail: 100}},
{fstype: "ufs", source: "/dev/da2p1", mountpoint: "/srv", fsid: coverFs, usage: rawUsage{total: 300, avail: 150}},
}
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/srv":
return resolvedOK("/srv", coverFs)
case "/srv/child":
// Hidden: resolves to the covering filesystem at /srv.
return resolvedOK("/srv", coverFs)
}
return resolvedMount{}
}
covering, nested := selectCapacityMounts(mounts, "/srv", lookup)
if covering == nil || covering.mountpoint != "/srv" {
t.Fatalf("covering = %+v, want mountpoint /srv", covering)
}
if len(nested) != 0 {
t.Fatalf("nested = %+v, want hidden /srv/child excluded", nested)
}
}
// TestSelectCapacityMountsUnreachableNestedSkipped covers the same hidden
// layout when the covered path no longer exists in the new filesystem: the
// lookup fails outright and the stale record is dropped.
func TestSelectCapacityMountsUnreachableNestedSkipped(t *testing.T) {
rootFs, coverFs := fsidVal(1, 1), fsidVal(3, 3)
mounts := []bsdMount{
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
{fstype: "ufs", source: "/dev/da2p1", mountpoint: "/srv", fsid: coverFs},
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/srv/gone", fsid: fsidVal(2, 2), usage: rawUsage{total: 200, avail: 100}},
}
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/srv":
return resolvedOK("/srv", coverFs)
}
return resolvedMount{} // ENOENT under the overmount
}
_, nested := selectCapacityMounts(mounts, "/srv", lookup)
if len(nested) != 0 {
t.Fatalf("nested = %+v, want unreachable /srv/gone excluded", nested)
}
}
// TestSelectCapacityMountsStackedTopmostOnly checks that of several records
// at one mountpoint only the one a lookup resolves to is counted.
func TestSelectCapacityMountsStackedTopmostOnly(t *testing.T) {
rootFs, lowerFs, upperFs := fsidVal(1, 1), fsidVal(4, 4), fsidVal(5, 5)
mounts := []bsdMount{
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/srv/data", fsid: lowerFs, usage: rawUsage{total: 200, avail: 100}},
{fstype: "tmpfs", source: "tmpfs", mountpoint: "/srv/data", fsid: upperFs, usage: rawUsage{total: 60, avail: 30}},
}
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/srv/data":
return resolvedOK("/srv/data", upperFs)
}
return resolvedMount{}
}
_, nested := selectCapacityMounts(mounts, "/srv", lookup)
if len(nested) != 1 || nested[0].fsid != upperFs {
t.Fatalf("nested = %+v, want only the topmost /srv/data record", nested)
}
}
// TestSelectCapacityMountsHiddenCoveringAncestor: when the deepest ancestor
// mount of root is itself hidden by an overmount, covering must fall back to
// the deepest visible ancestor.
func TestSelectCapacityMountsHiddenCoveringAncestor(t *testing.T) {
rootFs, overFs, staleFs := fsidVal(1, 1), fsidVal(8, 8), fsidVal(9, 9)
mounts := []bsdMount{
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
{fstype: "ufs", source: "/dev/da2p1", mountpoint: "/x", fsid: overFs},
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/x/y", fsid: staleFs, usage: rawUsage{total: 200, avail: 100}},
}
// /x was mounted over the parent chain of /x/y: lookups of /x/y resolve
// inside the /x filesystem.
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/x":
return resolvedOK("/x", overFs)
case "/x/y":
return resolvedOK("/x", overFs)
}
return resolvedMount{}
}
covering, nested := selectCapacityMounts(mounts, "/x/y/z", lookup)
if covering == nil || covering.mountpoint != "/x" {
t.Fatalf("covering = %+v, want deepest visible ancestor /x", covering)
}
if len(nested) != 0 {
t.Fatalf("nested = %+v, want none", nested)
}
}
// TestSelectCapacityMountsNestedVisibleKept is the baseline: a nested mount
// that still resolves to itself contributes its usage.
func TestSelectCapacityMountsNestedVisibleKept(t *testing.T) {
rootFs, zfsFs := fsidVal(1, 1), fsidVal(7, 7)
mounts := []bsdMount{
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
{fstype: "zfs", source: "tank/data", mountpoint: "/srv/data", fsid: zfsFs, usage: rawUsage{total: 400, avail: 200}},
}
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/srv/data":
return resolvedOK("/srv/data", zfsFs)
}
return resolvedMount{}
}
covering, nested := selectCapacityMounts(mounts, "/srv", lookup)
if covering == nil || covering.mountpoint != "/" {
t.Fatalf("covering = %+v, want /", covering)
}
if len(nested) != 1 || nested[0].mountpoint != "/srv/data" {
t.Fatalf("nested = %+v, want [/srv/data]", nested)
}
}
@@ -7,94 +7,64 @@ import (
"os"
"path/filepath"
"syscall"
"golang.org/x/sys/unix"
)
// GetAggregatedPartitionUsage returns total and used bytes summed across distinct
// filesystems mounted at or under root (deduped by major:minor from mountinfo).
// Falls back to a single-path probe when mountinfo cannot be read.
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
// GetPartitionUsageVariants returns two capacity views of root:
// - aggregate: total/used summed across distinct capacity groups mounted at or
// under root, deduped so shared-pool filesystems (ZFS datasets, btrfs
// subvolumes) are counted once per pool.
// - rootOnly: a single statfs of root, ignoring nested mounts.
//
// Falls back to the single-path probe for both when mountinfo cannot be read.
func GetPartitionUsageVariants(root string) (aggregate, rootOnly PartitionUsage, err error) {
if root == "" {
return 0, 0, fmt.Errorf("empty path")
return aggregate, rootOnly, fmt.Errorf("empty path")
}
root = filepath.Clean(root)
paths, parseErr := distinctMountPathsUnder(root)
if parseErr != nil || len(paths) == 0 {
return singlePathPartitionUsage(root)
rootOnly, rootErr := singlePathPartitionUsage(root)
groups, parseErr := mountGroupsUnder(root)
if parseErr != nil || len(groups) == 0 {
if rootErr != nil {
return aggregate, rootOnly, rootErr
}
return rootOnly, rootOnly, nil
}
var firstErr error
for _, path := range paths {
t, u, stErr := partitionUsageAt(path)
if stErr != nil {
if firstErr == nil {
firstErr = stErr
for _, group := range groups {
stats := make([]rawUsage, 0, len(group.paths))
for _, p := range group.paths {
stat, stErr := partitionStatfs(p)
if stErr != nil {
if firstErr == nil {
firstErr = stErr
}
continue
}
continue
stats = append(stats, stat)
}
total += t
used += u
u := combineGroupUsage(stats)
aggregate.Total += u.Total
aggregate.Used += u.Used
}
if total == 0 && used == 0 {
if aggregate.Total == 0 && aggregate.Used == 0 {
if firstErr != nil {
return 0, 0, firstErr
return aggregate, rootOnly, firstErr
}
return singlePathPartitionUsage(root)
aggregate = rootOnly
}
return total, used, nil
return aggregate, rootOnly, rootErr
}
func singlePathPartitionUsage(root string) (total, used uint64, err error) {
total, err = GetPartitionSize(root)
if err != nil {
return 0, 0, err
}
used, err = GetPartitionUsed(root)
if err != nil {
return 0, 0, err
}
return total, used, nil
}
func distinctMountPathsUnder(root string) ([]string, error) {
func mountGroupsUnder(root string) ([]mountGroup, error) {
data, err := readMountinfo()
if err != nil {
return nil, err
}
paths, err := distinctMountPathsFromMountinfo(data, root)
if err != nil {
return nil, err
}
// If mountinfo had no covering mount for root, ensure root is probed.
if len(paths) == 0 {
return []string{root}, nil
}
hasRoot := false
for _, p := range paths {
if p == root {
hasRoot = true
break
}
}
if !hasRoot {
if dev, stErr := deviceIDFromPath(root); stErr == nil {
// Avoid duplicating a device already selected under another path.
already := false
for _, p := range paths {
if d, e := deviceIDFromPath(p); e == nil && d == dev {
already = true
break
}
}
if !already {
paths = append(paths, root)
}
}
}
return paths, nil
return mountGroupsFromMountinfo(data, root)
}
func readMountinfo() (string, error) {
@@ -107,24 +77,14 @@ func readMountinfo() (string, error) {
return "", fmt.Errorf("mountinfo not available")
}
func deviceIDFromPath(path string) (string, error) {
var st unix.Stat_t
if err := unix.Stat(path, &st); err != nil {
return "", err
}
return fmt.Sprintf("%d:%d", unix.Major(uint64(st.Dev)), unix.Minor(uint64(st.Dev))), nil
}
func partitionUsageAt(path string) (total, used uint64, err error) {
func partitionStatfs(path string) (rawUsage, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, 0, err
return rawUsage{}, err
}
bsize := blockSize(&stat)
total = uint64(stat.Blocks) * bsize
free := uint64(stat.Bavail) * bsize
if total >= free {
used = total - free
}
return total, used, nil
return rawUsage{
total: uint64(stat.Blocks) * bsize,
avail: uint64(stat.Bavail) * bsize,
}, nil
}
@@ -12,55 +12,111 @@ import (
type mountEntry struct {
deviceID string
mountpoint string
fstype string
source string
}
// distinctMountPathsFromMountinfo selects one path per distinct filesystem that
// contributes capacity for root: the FS that owns root, plus nested mounts under
// root. Deduped by major:minor from mountinfo.
func distinctMountPathsFromMountinfo(mountinfo string, root string) ([]string, error) {
// mountGroup is a set of mountpoints that share one capacity pool. Groups keyed
// by filesystem identity get one probe path; shared-pool filesystems (zfs) keep
// every dataset mountpoint so per-dataset used space can be summed.
type mountGroup struct {
key string
fstype string
paths []string
}
// capacityKey identifies mounts that share the same underlying capacity.
// - zfs: every dataset has its own device ID but draws from the pool; group by pool.
// - btrfs: subvolume mounts have distinct device IDs but report the whole
// filesystem; group by mount source.
// - everything else: device major:minor, as before.
func (e *mountEntry) capacityKey() string {
switch e.fstype {
case "zfs", "zfsfuse":
return "zfs:" + zfsPoolName(e.source)
case "btrfs":
return "btrfs:" + e.source
default:
return "dev:" + e.deviceID
}
}
// mountGroupsFromMountinfo selects the mounts contributing capacity for root:
// the filesystem that owns root, plus nested mounts under root, grouped by
// capacity key. ZFS snapshot mounts are excluded.
func mountGroupsFromMountinfo(mountinfo string, root string) ([]mountGroup, error) {
root = path.Clean(root)
entries, err := parseAllMounts(mountinfo)
if err != nil {
return nil, err
}
seen := make(map[string]string)
groupIndex := make(map[string]int)
var groups []mountGroup
addPath := func(e *mountEntry, p string) {
key := e.capacityKey()
i, ok := groupIndex[key]
if !ok {
i = len(groups)
groupIndex[key] = i
groups = append(groups, mountGroup{key: key, fstype: e.fstype})
}
// Only shared-pool filesystems (zfs) need every mountpoint; other groups
// report identical statfs per mount, so one probe path suffices.
if !isZFSType(groups[i].fstype) && len(groups[i].paths) > 0 {
return
}
for _, existing := range groups[i].paths {
if existing == p {
return
}
}
groups[i].paths = append(groups[i].paths, p)
}
var covering *mountEntry
for i := range entries {
e := &entries[i]
mp := e.mountpoint
if mp == root || strings.HasPrefix(root, mp+"/") || mp == "/" {
if covering == nil || len(mp) > len(covering.mountpoint) {
// >= keeps the later entry: stacked mounts at one mountpoint are
// shadowed by the topmost (last in mountinfo order).
if covering == nil || len(mp) >= len(covering.mountpoint) {
covering = e
}
}
}
if covering != nil {
seen[covering.deviceID] = root
// Probe root itself for the covering filesystem: root may be a plain
// directory inside a mount whose mountpoint is an ancestor.
addPath(covering, root)
} else {
// No mount covers root (e.g. hidden bind mount); probe it directly so
// it is not dropped when nested mounts under root do exist.
groups = append(groups, mountGroup{key: "self:" + root, paths: []string{root}})
}
for _, e := range entries {
// Stacked mounts: only the topmost (last in mountinfo order) is reachable.
lastAtMountpoint := make(map[string]int)
for i := range entries {
lastAtMountpoint[entries[i].mountpoint] = i
}
for i := range entries {
e := &entries[i]
mp := e.mountpoint
if mp != root && !strings.HasPrefix(mp, root+"/") {
continue
}
if _, ok := seen[e.deviceID]; ok {
if lastAtMountpoint[mp] != i {
continue
}
seen[e.deviceID] = mp
if isZFSType(e.fstype) && isZFSSnapshotMount(e.source, mp) {
continue
}
addPath(e, mp)
}
out := make([]string, 0, len(seen))
uniq := make(map[string]struct{}, len(seen))
for _, p := range seen {
if _, ok := uniq[p]; ok {
continue
}
uniq[p] = struct{}{}
out = append(out, p)
}
return out, nil
return groups, nil
}
func parseAllMounts(mountinfo string) ([]mountEntry, error) {
@@ -88,9 +144,16 @@ func parseMountinfoLine(line string) (mountEntry, bool) {
if len(fields) < 5 {
return mountEntry{}, false
}
// After " - ": "fstype source super-options".
post := strings.Fields(parts[1])
if len(post) < 2 {
return mountEntry{}, false
}
return mountEntry{
deviceID: fields[2],
mountpoint: unescapeFstab(fields[4]),
fstype: post[0],
source: unescapeFstab(post[1]),
}, true
}
@@ -7,6 +7,25 @@ import (
"testing"
)
// allGroupPaths flattens groups into a sorted list of probe paths.
func allGroupPaths(groups []mountGroup) []string {
var out []string
for _, g := range groups {
out = append(out, g.paths...)
}
sort.Strings(out)
return out
}
func findGroup(groups []mountGroup, prefix string) *mountGroup {
for i := range groups {
if len(groups[i].key) >= len(prefix) && groups[i].key[:len(prefix)] == prefix {
return &groups[i]
}
}
return nil
}
func TestDistinctMountPathsNestedDisk(t *testing.T) {
// /srv lives on rootfs 8:1; /srv/data is a separate 2TB disk 8:16.
mountinfo := "" +
@@ -14,11 +33,11 @@ func TestDistinctMountPathsNestedDisk(t *testing.T) {
"45 22 8:16 /data /srv/data rw - btrfs /dev/sdb1 rw\n" +
"50 22 0:47 / /proc rw - proc proc rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
sort.Strings(paths)
paths := allGroupPaths(groups)
want := []string{"/srv", "/srv/data"}
if len(paths) != len(want) {
t.Fatalf("paths = %v, want %v", paths, want)
@@ -37,11 +56,11 @@ func TestDistinctMountPathsOvermountSamePathDeduped(t *testing.T) {
"45 40 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n" +
"46 45 0:47 / /srv/data rw - tmpfs tmpfs rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
sort.Strings(paths)
paths := allGroupPaths(groups)
want := []string{"/srv", "/srv/data"}
if len(paths) != len(want) {
t.Fatalf("paths = %v, want %v (no duplicate probe paths)", paths, want)
@@ -56,15 +75,15 @@ func TestDistinctMountPathsOvermountSamePathDeduped(t *testing.T) {
func TestDistinctMountPathsBindSameDeviceNotDuplicated(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"45 22 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n" +
"46 22 8:16 / /srv/data/bind rw,bind - btrfs /dev/sdb1 rw\n"
"45 22 8:16 / /srv/data rw - ext4 /dev/sdb1 rw\n" +
"46 22 8:16 / /srv/data/bind rw,bind - ext4 /dev/sdb1 rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
if len(paths) != 2 {
t.Fatalf("expected 2 distinct devices, got %v", paths)
if len(groups) != 2 {
t.Fatalf("expected 2 distinct capacity groups, got %v", groups)
}
}
@@ -74,11 +93,11 @@ func TestDistinctMountPathsSourceIsOwnMount(t *testing.T) {
"40 22 8:2 / /srv rw - ext4 /dev/sda2 rw\n" +
"45 40 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
sort.Strings(paths)
paths := allGroupPaths(groups)
want := []string{"/srv", "/srv/data"}
if len(paths) != 2 || paths[0] != want[0] || paths[1] != want[1] {
t.Fatalf("paths = %v, want %v", paths, want)
@@ -90,10 +109,11 @@ func TestDistinctMountPathsIgnoresUnrelated(t *testing.T) {
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"30 22 8:3 / /home rw - ext4 /dev/sda3 rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
paths := allGroupPaths(groups)
if len(paths) != 1 || paths[0] != "/srv" {
t.Fatalf("paths = %v, want [/srv]", paths)
}
@@ -110,4 +130,124 @@ func TestParseMountinfoEscapedSpace(t *testing.T) {
if entry.deviceID != "98:0" {
t.Fatalf("deviceID = %q, want 98:0", entry.deviceID)
}
if entry.fstype != "ext3" {
t.Fatalf("fstype = %q, want ext3", entry.fstype)
}
if entry.source != "/dev/root" {
t.Fatalf("source = %q, want /dev/root", entry.source)
}
}
// TestMountGroupsZFSPoolDatasetsGrouped reproduces issue #3025: every ZFS
// dataset gets its own device ID, so major:minor dedup counts pool capacity
// once per dataset. They must share one capacity group.
func TestMountGroupsZFSPoolDatasetsGrouped(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"40 22 0:55 / /srv/pool rw - zfs pool rw\n" +
"41 40 0:56 / /srv/pool/ds01 rw - zfs pool/ds01 rw\n" +
"42 41 0:57 / /srv/pool/ds01/a rw - zfs pool/ds01/a rw\n" +
"43 40 0:58 / /srv/pool/ds02 rw - zfs pool/ds02 rw\n" +
"44 22 0:59 / /other/pool2 rw - zfs pool2 rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv/pool")
if err != nil {
t.Fatal(err)
}
g := findGroup(groups, "zfs:pool")
if g == nil || g.key != "zfs:pool" {
t.Fatalf("expected one zfs:pool group, got %v", groups)
}
want := []string{"/srv/pool", "/srv/pool/ds01", "/srv/pool/ds01/a", "/srv/pool/ds02"}
got := append([]string{}, g.paths...)
sort.Strings(got)
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("zfs group paths = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("zfs group paths = %v, want %v", got, want)
}
}
// The unrelated second pool must not join the group.
if g2 := findGroup(groups, "zfs:pool2"); g2 != nil {
t.Fatalf("unrelated pool2 should not be included: %v", groups)
}
}
func TestMountGroupsZFSSnapshotsSkipped(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"40 22 0:55 / /srv/pool rw - zfs pool rw\n" +
"41 40 0:56 / /srv/pool/ds01 rw - zfs pool/ds01 rw\n" +
"42 41 0:57 / /srv/pool/ds01/.zfs/snapshot/snap1 rw - zfs pool/ds01@snap1 rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv/pool")
if err != nil {
t.Fatal(err)
}
paths := allGroupPaths(groups)
for _, p := range paths {
if p == "/srv/pool/ds01/.zfs/snapshot/snap1" {
t.Fatalf("snapshot mount should be skipped: %v", paths)
}
}
if len(paths) != 2 {
t.Fatalf("paths = %v, want 2 live datasets", paths)
}
}
func TestMountGroupsBtrfsSubvolumesDeduped(t *testing.T) {
// Each btrfs subvolume mount has a distinct anonymous device but reports
// whole-filesystem statfs; they must collapse into one group.
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"40 22 0:33 /@ /srv rw - btrfs /dev/sdb1 rw\n" +
"41 40 0:34 /@home /srv/home rw - btrfs /dev/sdb1 rw\n" +
"42 40 0:35 /@data /srv/data rw - btrfs /dev/sdb1 rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
g := findGroup(groups, "btrfs:")
if g == nil || g.key != "btrfs:/dev/sdb1" {
t.Fatalf("expected one btrfs group, got %v", groups)
}
// Subvolume mounts report identical fs-wide statfs; one probe path suffices.
if len(g.paths) != 1 || g.paths[0] != "/srv" {
t.Fatalf("btrfs group paths = %v, want [/srv]", g.paths)
}
if len(groups) != 1 {
t.Fatalf("expected exactly 1 group for /srv, got %v", groups)
}
}
// TestCombineGroupUsageZFSPool verifies the arithmetic from #3025: 26 datasets
// each reporting ~19.7T avail must yield used+19.7T total, not 26*19.7T+used.
func TestCombineGroupUsageZFSPool(t *testing.T) {
const avail = uint64(19_700_000_000_000) // ~19.7 TiB pool free
stats := []rawUsage{{total: 20_000_000_000_000, avail: avail}} // root ds: 0.3T used
for i := 0; i < 25; i++ {
stats = append(stats, rawUsage{total: avail + 100_000_000_000, avail: avail})
}
// One quota'd child: reports a smaller avail; must not shrink the pool.
stats = append(stats, rawUsage{total: 1_000_000_000_000, avail: 500_000_000_000})
u := combineGroupUsage(stats)
wantUsed := uint64(300_000_000_000) + 25*uint64(100_000_000_000) + 500_000_000_000
if u.Used != wantUsed {
t.Fatalf("used = %d, want %d", u.Used, wantUsed)
}
if u.Total != wantUsed+avail {
t.Fatalf("total = %d, want %d (used+single pool avail)", u.Total, wantUsed+avail)
}
}
func TestCombineGroupUsageSingleMountIdentity(t *testing.T) {
u := combineGroupUsage([]rawUsage{{total: 1000, avail: 400}})
if u.Total != 1000 || u.Used != 600 {
t.Fatalf("single mount = %+v, want total 1000 used 600", u)
}
}
@@ -1,17 +1,14 @@
//go:build !linux
//go:build !linux && !freebsd
package fileutils
// GetAggregatedPartitionUsage returns total and used bytes for the filesystem
// that owns root. Non-Linux builds use a single statfs/GetDiskFreeSpaceEx path.
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
total, err = GetPartitionSize(root)
// GetPartitionUsageVariants returns the aggregate and root-only capacity views
// of root. Without mount info only a single statfs of the source path is
// available, so both variants are identical.
func GetPartitionUsageVariants(root string) (aggregate, rootOnly PartitionUsage, err error) {
u, err := singlePathPartitionUsage(root)
if err != nil {
return 0, 0, err
return aggregate, rootOnly, err
}
used, err = GetPartitionUsed(root)
if err != nil {
return 0, 0, err
}
return total, used, nil
return u, u, nil
}
@@ -0,0 +1,50 @@
//go:build linux || freebsd
package fileutils
import "strings"
// rawUsage is a single filesystem measurement in bytes.
type rawUsage struct {
total uint64
avail uint64
}
// combineGroupUsage folds statfs results for mounts that share one capacity pool
// into a single total/used pair. Per-mount used (total-avail) is summed, while
// shared free space is counted once via the largest reported avail. This keeps
// ZFS datasets or btrfs subvolumes from multiplying the pool's capacity by the
// number of mounts under a source.
func combineGroupUsage(stats []rawUsage) PartitionUsage {
var used, avail uint64
for _, s := range stats {
if s.total > s.avail {
used += s.total - s.avail
}
if s.avail > avail {
avail = s.avail
}
}
return PartitionUsage{Total: used + avail, Used: used}
}
// isZFSType reports whether fstype is a ZFS variant whose datasets share pool
// capacity across mounts (kernel zfs or zfsfuse).
func isZFSType(fstype string) bool {
return fstype == "zfs" || fstype == "zfsfuse"
}
// zfsPoolName returns the pool component of a ZFS mount source ("pool/ds@snap" -> "pool").
func zfsPoolName(source string) string {
if i := strings.IndexAny(source, "/@"); i >= 0 {
return source[:i]
}
return source
}
// isZFSSnapshotMount reports whether a ZFS mount is a snapshot (.zfs snapdir or
// an "@snapshot" source). Snapshots share pool space with the live datasets and
// are excluded from capacity aggregation.
func isZFSSnapshotMount(source, mountpoint string) bool {
return strings.Contains(source, "@") || strings.Contains(mountpoint, "/.zfs/")
}
+1 -1
View File
@@ -14,5 +14,5 @@ type JSONAuth struct {
// Auth authenticates the user via a json in content body (legacy method for compatibility).
func (auther JSONAuth) Auth(r *http.Request, _ *users.Storage) (*users.User, error) {
return AuthenticatePassword(r, auther.ReCaptcha)
return AuthenticatePassword(r, auther.ReCaptcha, false)
}
+7 -3
View File
@@ -30,7 +30,8 @@ func SetDefault(s *Service) {
}
// AuthenticatePassword authenticates the user via password in request headers.
func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users.User, error) {
// When skipRequirePasswordChange is true, a valid password is accepted even if RequirePasswordChange is set (change-required flow).
func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool, skipRequirePasswordChange bool) (*users.User, error) {
if s == nil || s.users == nil {
return nil, fmt.Errorf("auth service not configured")
}
@@ -63,6 +64,9 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users
if getErr != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
if user.RequirePasswordChange && user.LoginMethod == users.LoginMethodPassword && !skipRequirePasswordChange {
return nil, errors.ErrPasswordChangeRequired
}
if user.TOTPSecret != "" && !disableOtp {
if totpCode == "" {
return nil, errors.ErrNoTotpProvided
@@ -80,9 +84,9 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users
}
// AuthenticatePassword authenticates via the default service.
func AuthenticatePassword(r *http.Request, disableOtp bool) (*users.User, error) {
func AuthenticatePassword(r *http.Request, disableOtp bool, skipRequirePasswordChange bool) (*users.User, error) {
if defaultService != nil {
return defaultService.AuthenticatePassword(r, disableOtp)
return defaultService.AuthenticatePassword(r, disableOtp, skipRequirePasswordChange)
}
return nil, fmt.Errorf("auth service not configured")
}
@@ -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"`
@@ -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")
}
+25 -24
View File
@@ -106,11 +106,11 @@ type MinimalAuthToken struct {
}
type Permissions struct {
Api bool `json:"api"` // allow api access
Admin bool `json:"admin"` // allow admin access
Api bool `json:"api"` // allow api access
Admin bool `json:"admin"` // allow admin access
Modify bool `json:"modify,omitempty"` // deprecated: legacy user migration only; not used for API tokens
Share bool `json:"share"` // allow sharing files
Realtime bool `json:"realtime"` // allow realtime updates
Share bool `json:"share"` // allow sharing files
Realtime bool `json:"realtime"` // allow realtime updates
Delete bool `json:"delete,omitempty"` // deprecated: legacy user migration only; not used for API tokens
Create bool `json:"create,omitempty"` // deprecated: legacy user migration only; not used for API tokens
Download bool `json:"download,omitempty"` // deprecated: legacy user migration only; not used for API tokens
@@ -119,12 +119,12 @@ type Permissions struct {
// SourceFilePermissions holds per-source file operation permissions (v4+).
type SourceFilePermissions struct {
View bool `json:"view"`
Download bool `json:"download"`
Modify bool `json:"modify"`
Delete bool `json:"delete"`
Create bool `json:"create"`
Configured bool `json:"configured,omitempty"` // true when explicitly set (allows intentional deny-all)
View bool `json:"view"`
Download bool `json:"download"`
Modify bool `json:"modify"`
Delete bool `json:"delete"`
Create bool `json:"create"`
Configured bool `json:"configured,omitempty"` // true when explicitly set (allows intentional deny-all)
}
// MarkSourceFilePermissionsConfigured marks permissions as explicitly set (including deny-all).
@@ -173,16 +173,17 @@ type Preview struct {
// FrontendUser holds fields safe to return from user APIs (embedded on User).
type FrontendUser struct {
NonAdminEditable
DisableSettings bool `json:"disableSettings"`
Username string `json:"username"`
FrontendScopes []FrontendScope `json:"scopes"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"`
ShowFirstLogin bool `json:"showFirstLogin"`
Perm Permissions `json:"perm,omitzero"`
DisableSettings bool `json:"disableSettings"`
Username string `json:"username"`
FrontendScopes []FrontendScope `json:"scopes"`
LockPassword bool `json:"lockPassword"`
RequirePasswordChange bool `json:"requirePasswordChange"`
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"`
ShowFirstLogin bool `json:"showFirstLogin"`
Perm Permissions `json:"perm,omitzero"`
}
// PinnedItems maps source filesystem path -> index directory path -> pinned item names.
@@ -211,14 +212,14 @@ type User struct {
}
type FrontendScope struct {
Name string `json:"name"` // Bolt: filesystem path; JSON API: display name after prepForFrontend
Scope string `json:"scope"` // index path within that source
Name string `json:"name"` // Bolt: filesystem path; JSON API: display name after prepForFrontend
Scope string `json:"scope"` // index path within that source
Permissions *SourceFilePermissions `json:"permissions,omitempty"`
}
type BackendScope struct {
Path string `json:"path"` // real path for the source
Scope string `json:"scope"` // index path within that source
Path string `json:"path"` // real path for the source
Scope string `json:"scope"` // index path within that source
Permissions SourceFilePermissions `json:"permissions,omitempty"`
}
+2 -1
View File
@@ -18,7 +18,8 @@ var (
ErrAccessDenied = errors.New("access denied")
ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent")
ErrNoTotpProvided = errors.New("OTP code is required for user")
ErrNoTotpProvided = errors.New("OTP code is required for user")
ErrPasswordChangeRequired = errors.New("password change is required for user")
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not configured")
ErrUnauthorized = errors.New("user unauthorized")
ErrNotIndexed = errors.New("directory or item excluded from indexing")
@@ -1,24 +1,23 @@
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.
// When config adminPassword is blank or "admin", 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.Config.Auth.AdminPassword
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)
}
@@ -29,3 +28,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 TestBootstrapDefaultAdminPasswordUsesConfigWhenSet(t *testing.T) {
orig := settings.Config.Auth.AdminPassword
defer func() { settings.Config.Auth.AdminPassword = orig }()
@@ -24,6 +31,8 @@ func TestBootstrapDefaultAdminPasswordUsesConfigWhenSet(t *testing.T) {
}
}
var speakableBootstrapPasswordPattern = regexp.MustCompile(`^[a-z]+-[abcdefghjkmnpqrstuvwxyz23456789]{5}-[abcdefghjkmnpqrstuvwxyz23456789]{2}$`)
func TestBootstrapDefaultAdminPasswordGeneratesWhenDefault(t *testing.T) {
orig := settings.Config.Auth.AdminPassword
defer func() { settings.Config.Auth.AdminPassword = orig }()
@@ -40,11 +49,31 @@ func TestBootstrapDefaultAdminPasswordGeneratesWhenDefault(t *testing.T) {
if plain == "" || plain == "admin" {
t.Fatalf("cfg=%q: unexpected password %q", cfg, plain)
}
if len(plain) != bootstrapAdminPasswordHexBytes*2 {
t.Fatalf("cfg=%q: password length %d, want %d", cfg, len(plain), bootstrapAdminPasswordHexBytes*2)
if !speakableBootstrapPasswordPattern.MatchString(plain) {
t.Fatalf("cfg=%q: password %q does not match speakable pattern", cfg, plain)
}
if strings.Trim(plain, "0123456789abcdef") != "" {
t.Fatalf("cfg=%q: expected hex password, got %q", cfg, plain)
word := strings.Split(plain, "-")[0]
found := false
for _, w := range bootstrapWords {
if w == word {
found = true
break
}
}
if !found {
t.Fatalf("cfg=%q: word %q not in bootstrap word list", cfg, word)
}
}
}
func TestGenerateSpeakableBootstrapPassword(t *testing.T) {
for i := 0; i < 20; i++ {
plain, err := generateSpeakableBootstrapPassword()
if err != nil {
t.Fatal(err)
}
if !speakableBootstrapPasswordPattern.MatchString(plain) {
t.Fatalf("password %q does not match speakable pattern", plain)
}
}
}
@@ -0,0 +1,23 @@
package state
// bootstrapWords is a fixed list of short, common English nouns for one-time bootstrap passwords.
// Words are chosen to be easy to say, spell, and type (including for non-native English speakers).
var bootstrapWords = []string{
"apple", "ball", "barn", "bean", "bed", "belt", "bike", "bird", "boat", "book",
"boot", "bowl", "box", "bus", "cake", "camp", "cart", "cat", "clock", "cloud",
"coat", "coin", "corn", "cup", "desk", "dog", "door", "duck", "egg", "farm",
"fish", "flag", "food", "foot", "game", "gift", "goat", "gold", "grass", "hand",
"hat", "hill", "home", "horse", "house", "ice", "jar", "key", "kid", "lake",
"lamp", "land", "leaf", "leg", "light", "line", "lion", "list", "mail", "map",
"milk", "moon", "mouse", "nest", "net", "night", "nose", "note", "page", "park",
"path", "pen", "pet", "pie", "pig", "pin", "pipe", "plan", "plant", "pot",
"rain", "red", "ring", "road", "rock", "room", "rope", "rose", "sand", "sea",
"ship", "shoe", "shop", "sky", "snow", "sock", "star", "stone", "sun", "tree",
}
// bootstrapSpeakableCharset excludes ambiguous characters for read-aloud (no 0/o, 1/i/l).
const bootstrapSpeakableCharset = "abcdefghjkmnpqrstuvwxyz23456789"
// bootstrapSpeakablePrimaryCodeLen and bootstrapSpeakableSecondaryCodeLen form the code after the word (e.g. word-xxxxx-xx).
const bootstrapSpeakablePrimaryCodeLen = 5
const bootstrapSpeakableSecondaryCodeLen = 2
+4 -1
View File
@@ -55,10 +55,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 {
+10
View File
@@ -189,6 +189,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)
@@ -202,7 +205,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
ApplyUserDefaults(user)
if preserveRequirePasswordChange {
user.RequirePasswordChange = true
}
defaults := EffectiveUserDefaults()
enforced := EffectiveEnforced()
settings.ApplyEnforcedDefaultsFrom(user, defaults, enforced)
@@ -289,6 +296,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)
+7 -2
View File
@@ -23,8 +23,13 @@ func FrontendLinks(links []users.SidebarLink, showToolsInSidebar bool) []users.S
}
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
+96 -22
View File
@@ -11,6 +11,7 @@ import (
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"golang.org/x/crypto/bcrypt"
@@ -80,6 +81,10 @@ func ExtractToken(r *http.Request) (string, error) {
// getOrCreateAuthenticatedUser is a common helper for retrieving or auto-creating users
// across different authentication methods (proxy, JWT, LDAP, OIDC)
func getOrCreateAuthenticatedUser(username string, loginMethod users.LoginMethod, isAdmin bool, groups []string) (*users.User, error) {
if err := ensureUserInAllowedGroups(loginMethod, groups); err != nil {
return nil, err
}
// Try to get existing user
userValue, err := state.GetUserByUsername(username)
if err != nil {
@@ -113,27 +118,6 @@ func getOrCreateAuthenticatedUser(username string, loginMethod users.LoginMethod
return nil, err
}
}
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
}
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")
}
// Sync admin status if needed (in case admin username changed)
if isAdmin && !userValue.Permissions.Admin {
userValue.Permissions.Admin = true
@@ -159,6 +143,36 @@ func getOrCreateAuthenticatedUser(username string, loginMethod users.LoginMethod
return &userValue, nil
}
// 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
}
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 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) {
// Check if username matches admin username
isAdmin := proxyUser == settings.Config.Auth.AdminUsername
@@ -207,6 +221,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
@@ -307,6 +324,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{
@@ -355,12 +375,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)
@@ -371,7 +445,7 @@ func printToken(w http.ResponseWriter, r *http.Request, user *users.User, priorT
return 401, errors.ErrUnauthorized
}
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
expiresTime := time.Now().Add(expires).Add(sessionCookieBuffer)
SetSessionCookie(w, r, tokenString, expiresTime)
+74 -1
View File
@@ -61,6 +61,79 @@ func TestGetOrCreateAuthenticatedUserRejectsPasswordUserForOIDCAccount(t *testin
}
}
func TestGetOrCreateAuthenticatedUserLDAPUserGroupsCNMatch(t *testing.T) {
setupTestEnv(t)
orig := settings.Config.Auth.Methods.LdapAuth.UserGroups
settings.Config.Auth.Methods.LdapAuth.UserGroups = []string{"employees"}
t.Cleanup(func() {
settings.Config.Auth.Methods.LdapAuth.UserGroups = orig
})
ldapUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "ldap-user",
LoginMethod: users.LoginMethodLdap,
},
}
if err := state.CreateUser(ldapUser, ""); err != nil {
t.Fatalf("CreateUser: %v", err)
}
groups := []string{"cn=Employees,ou=groups,dc=example,dc=com"}
_, err := getOrCreateAuthenticatedUser("ldap-user", users.LoginMethodLdap, false, groups)
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)
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)
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)
}
+53 -19
View File
@@ -151,25 +151,8 @@ func downloadHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, e
// @Failure 501 {object} map[string]string "Downloads disabled for upload shares"
// @Router /public/api/resources/download [get]
func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("downloads are disabled for upload shares")
}
if d.Share.DisableDownload {
return http.StatusForbidden, fmt.Errorf("downloads are not allowed for this share")
}
if !d.Share.PerUserDownloadLimit && d.Share.DownloadsLimit > 0 && d.Share.Downloads >= d.Share.DownloadsLimit {
return http.StatusForbidden, fmt.Errorf("share downloads limit reached")
}
if d.Share.PerUserDownloadLimit {
if d.User.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("anonymous downloads are not allowed with per-user limits")
}
if d.Share.HasReachedUserLimit(d.User.Username) {
return http.StatusForbidden, fmt.Errorf("user download limit reached for this share")
}
if status, err := publicShareDownloadGate(d); err != nil {
return status, err
}
files := r.URL.Query()["file"]
@@ -213,6 +196,57 @@ func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *Context) (
return status, nil
}
// publicShareDownloadGate enforces upload-share, disable-download, and download-counter rules for public share byte egress.
func publicShareDownloadGate(d *Context) (int, error) {
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("downloads are disabled for upload shares")
}
if d.Share.DisableDownload {
return http.StatusForbidden, fmt.Errorf("downloads are not allowed for this share")
}
if status, err := publicShareDownloadLimits(d); err != nil {
return status, err
}
return 0, nil
}
func publicShareDownloadLimits(d *Context) (int, error) {
if !d.Share.PerUserDownloadLimit && d.Share.DownloadsLimit > 0 && d.Share.Downloads >= d.Share.DownloadsLimit {
return http.StatusForbidden, fmt.Errorf("share downloads limit reached")
}
if d.Share.PerUserDownloadLimit {
if d.User.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("anonymous downloads are not allowed with per-user limits")
}
if d.Share.HasReachedUserLimit(d.User.Username) {
return http.StatusForbidden, fmt.Errorf("user download limit reached for this share")
}
}
return 0, nil
}
// publicShareMediaContentPolicy applies the same share view/download flags and download limits as other public content routes.
func publicShareMediaContentPolicy(d *Context) (int, error) {
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
}
sourceInfo, ok := settings.Config.Server.SourceMap[d.Share.SourcePath]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
filePerms, err := effectiveFilePerms(d, sourceInfo.Name)
if err != nil {
return http.StatusForbidden, err
}
if !filePerms.View {
return http.StatusForbidden, fmt.Errorf("user is not allowed to view files in this source")
}
if !filePerms.Download {
return http.StatusForbidden, fmt.Errorf("downloads are not allowed for this share")
}
return publicShareDownloadLimits(d)
}
func RawFilesHandler(w http.ResponseWriter, r *http.Request, d *Context, source string, fileList []string) (int, error) {
if d.Share.Hash == "" {
filePerms, err := effectiveFilePerms(d, source)
+1
View File
@@ -35,6 +35,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)))
+48 -36
View File
@@ -158,56 +158,68 @@ func pickUserEntry(entries []*ldap.Entry) *ldap.Entry {
return user
}
// ldapGroupMatchesAdmin returns true if the LDAP group DN matches the configured admin group (full DN or CN value).
func ldapGroupMatchesAdmin(groupDN, adminGroup string) bool {
g := strings.TrimSpace(groupDN)
if g == adminGroup {
return true
}
dn, err := ldap.ParseDN(g)
if err != nil {
// ldapGroupMatches reports whether an LDAP group membership value matches a
// configured group. Matching is case-insensitive and accepts a full DN or a
// CN-only value. When both sides are DNs, the complete DN must match (case-
// insensitive); CN-only fallback applies only when one side is not a DN, so
// groups that share a CN under different parents cannot authorize each other.
func ldapGroupMatches(member, configured string) bool {
member = strings.TrimSpace(member)
configured = strings.TrimSpace(configured)
if configured == "" {
return false
}
for _, rdn := range dn.RDNs {
for _, attr := range rdn.Attributes {
if strings.EqualFold(attr.Type, "cn") && attr.Value == adminGroup {
return true
}
}
if strings.EqualFold(member, configured) {
return true
}
memberIsDN := ldapIsDN(member)
configuredIsDN := ldapIsDN(configured)
memberCN := ldapCN(member)
configuredCN := ldapCN(configured)
// Config CN-only vs member full DN.
if memberIsDN && !configuredIsDN && memberCN != "" && strings.EqualFold(memberCN, configured) {
return true
}
// Config full DN vs member CN-only.
if configuredIsDN && !memberIsDN && configuredCN != "" && strings.EqualFold(member, configuredCN) {
return true
}
return false
}
// ldapIsDN reports whether s parses as an LDAP distinguished name.
func ldapIsDN(s string) bool {
_, err := ldap.ParseDN(s)
return err == nil
}
// ldapCN returns the first CN RDN value from an LDAP DN string, or "" if none.
func ldapCN(s string) string {
dn, err := ldap.ParseDN(s)
if err != nil {
return ""
}
for _, rdn := range dn.RDNs {
for _, attr := range rdn.Attributes {
if strings.EqualFold(attr.Type, "cn") {
return attr.Value
}
}
}
return ""
}
// getOrCreateLdapUser returns the filebrowser user for an LDAP-authenticated username, creating one if configured.
// userGroups authorization is enforced in getOrCreateAuthenticatedUser via ldapGroupMatches.
func getOrCreateLdapUser(username string, groups []string) (*users.User, error) {
logger.Debugf("getting or creating ldap user %s", username)
ldapCfg := settings.Config.Auth.Methods.LdapAuth
// Check if user is in required groups (if userGroups is configured)
if len(ldapCfg.UserGroups) > 0 {
userInAllowedGroup := false
for _, allowedGroup := range ldapCfg.UserGroups {
for _, userGroup := range groups {
if ldapGroupMatchesAdmin(userGroup, allowedGroup) {
userInAllowedGroup = true
break
}
}
if userInAllowedGroup {
break
}
}
if !userInAllowedGroup {
logger.Warningf("User %s is not in any of the required groups %v. Access denied.", username, ldapCfg.UserGroups)
return nil, fmt.Errorf("user %s is not authorized to access this application (not in required groups)", username)
}
logger.Debugf("User %s is in required group, allowing access.", username)
}
isAdmin := false
if ldapCfg.AdminGroup != "" {
for _, g := range groups {
if ldapGroupMatchesAdmin(g, ldapCfg.AdminGroup) {
if ldapGroupMatches(g, ldapCfg.AdminGroup) {
isAdmin = true
break
}
+44 -20
View File
@@ -90,61 +90,85 @@ func TestPickUserEntry(t *testing.T) {
}
}
func TestLdapGroupMatchesAdmin(t *testing.T) {
func TestLdapGroupMatches(t *testing.T) {
tests := []struct {
name string
groupDN string
adminGroup string
member string
configured string
want bool
}{
{
name: "exact DN match",
groupDN: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
adminGroup: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
member: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
configured: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
want: true,
},
{
name: "CN match",
groupDN: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
adminGroup: "authentik Admins",
member: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
configured: "authentik Admins",
want: true,
},
{
name: "CN match case-insensitive",
member: "cn=it department,ou=groups,dc=example,dc=com",
configured: "IT Department",
want: true,
},
{
name: "full DN match case-insensitive",
member: "CN=Employees,OU=Groups,DC=Example,DC=COM",
configured: "cn=employees,ou=groups,dc=example,dc=com",
want: true,
},
{
name: "config full DN vs member CN-only",
member: "Employees",
configured: "cn=Employees,ou=groups,dc=example,dc=com",
want: true,
},
{
name: "CN no match",
groupDN: "cn=Other Group,ou=groups,dc=test",
adminGroup: "authentik Admins",
member: "cn=Other Group,ou=groups,dc=test",
configured: "authentik Admins",
want: false,
},
{
name: "exact no match",
groupDN: "cn=admins,ou=groups,dc=test",
adminGroup: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
member: "cn=admins,ou=groups,dc=test",
configured: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
want: false,
},
{
name: "same CN different parents rejected",
member: "cn=Admins,ou=Other,dc=example,dc=com",
configured: "cn=Admins,ou=Privileged,dc=example,dc=com",
want: false,
},
{
name: "whitespace trimmed",
groupDN: " cn=admins,ou=groups,dc=test ",
adminGroup: "cn=admins,ou=groups,dc=test",
member: " cn=admins,ou=groups,dc=test ",
configured: "cn=admins,ou=groups,dc=test",
want: true,
},
{
name: "invalid DN returns false",
groupDN: "not-a-valid-dn",
adminGroup: "admins",
member: "not-a-valid-dn",
configured: "admins",
want: false,
},
{
name: "empty adminGroup no match",
groupDN: "cn=admins,ou=groups,dc=test",
adminGroup: "",
name: "empty configured no match",
member: "cn=admins,ou=groups,dc=test",
configured: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ldapGroupMatchesAdmin(tt.groupDN, tt.adminGroup)
got := ldapGroupMatches(tt.member, tt.configured)
if got != tt.want {
t.Errorf("ldapGroupMatchesAdmin(%q, %q) = %v, want %v", tt.groupDN, tt.adminGroup, got, tt.want)
t.Errorf("ldapGroupMatches(%q, %q) = %v, want %v", tt.member, tt.configured, got, tt.want)
}
})
}
+23 -35
View File
@@ -2,7 +2,6 @@ package web
import (
"bytes"
"errors"
"fmt"
"net/http"
"path/filepath"
@@ -242,7 +241,15 @@ func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *Context) (
func lyricsHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if path == "" || source == "" {
fileUser := d.User
if d.Share.Hash != "" {
source = d.Share.GetSourceName()
if source == "" {
return http.StatusNotFound, fmt.Errorf("source not found")
}
path = d.IndexPath
fileUser = d.ShareUser
} else if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
filePerms, err := effectiveFilePerms(d, source)
@@ -253,17 +260,22 @@ func lyricsHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
return http.StatusForbidden, fmt.Errorf("user is not allowed to view files in this source")
}
showHidden := d.User.ShowHidden
hideFileExt := d.User.HideFileExt
if d.Share.Hash != "" {
showHidden = d.Share.ShowHidden
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
FollowSymlinks: d.Share.Hash == "",
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: false,
ShowHidden: d.User.ShowHidden,
HideFileExt: d.User.HideFileExt,
ShowHidden: showHidden,
HideFileExt: hideFileExt,
SkipExtendedAttrs: false,
}, d.User)
}, fileUser)
if err != nil {
return ErrToStatus(err), err
}
@@ -289,34 +301,10 @@ func lyricsHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
// @Failure 404 {object} map[string]string "Not found"
// @Router /public/api/media/lyrics [get]
func publicLyricsHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
sourceCfg, ok := settings.Config.Server.SourceMap[d.Share.SourcePath]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
if status, err := publicShareMediaContentPolicy(d); err != nil {
return status, err
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: d.IndexPath,
Source: sourceCfg.Name,
Expand: true,
Content: false,
Metadata: false,
ShowHidden: d.Share.ShowHidden,
HideFileExt: d.User.HideFileExt,
FollowSymlinks: false,
SkipExtendedAttrs: false,
}, d.ShareUser)
if err != nil {
return ErrToStatus(err), err
}
if !strings.HasPrefix(fileInfo.Type, "audio") {
return http.StatusNotFound, fmt.Errorf("file is not an audio file")
}
lyrics, 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})
return lyricsHandler(w, r, d)
}
// publicSubtitlesHandler is the share-link variant of subtitlesHandler.
@@ -330,8 +318,8 @@ func publicLyricsHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
// @Success 200 {string} string "Raw subtitle content"
// @Router /public/api/media/subtitles [get]
func publicSubtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
if status, err := publicShareMediaContentPolicy(d); err != nil {
return status, err
}
return subtitlesHandler(w, r, d)
}
+96
View File
@@ -0,0 +1,96 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func publicMediaShareContext(owner *users.User, sourceRoot string, editable share.ShareEditable) *Context {
link := share.Share{
ShareColumns: share.ShareColumns{
Hash: "mediashare",
Path: "/shared",
},
FrontendShareInfo: editable.FrontendShareInfo,
ShareLimits: editable.ShareLimits,
SourcePath: sourceRoot,
}
return &Context{
User: &users.User{
FrontendUser: users.FrontendUser{Username: users.AnonymousUserName},
},
ShareUser: owner,
IndexPath: "/shared/song.mp3",
Share: link,
}
}
func TestPublicLyricsHandler_BlocksWhenFileViewerDisabled(t *testing.T) {
sourceRoot, _, owner := setupShareArchiveDownloadTest(t)
d := publicMediaShareContext(owner, sourceRoot, share.ShareEditable{DisableFileViewer: true})
req := httptest.NewRequest(http.MethodGet, "/public/api/media/lyrics?hash=mediashare&path=/song.mp3", nil)
rec := httptest.NewRecorder()
status, err := publicLyricsHandler(rec, req, d)
if err == nil {
t.Fatal("expected error when file viewer is disabled")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got status=%d err=%v", status, err)
}
}
func TestPublicLyricsHandler_BlocksWhenDownloadLimitReached(t *testing.T) {
sourceRoot, _, owner := setupShareArchiveDownloadTest(t)
d := publicMediaShareContext(owner, sourceRoot, share.ShareEditable{
DownloadsLimit: 1,
})
d.Share.Downloads = 1
req := httptest.NewRequest(http.MethodGet, "/public/api/media/lyrics?hash=mediashare&path=/song.mp3", nil)
rec := httptest.NewRecorder()
status, err := publicLyricsHandler(rec, req, d)
if err == nil {
t.Fatal("expected error when download limit is exhausted")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got status=%d err=%v", status, err)
}
}
func TestPublicSubtitlesHandler_BlocksWhenDownloadLimitReached(t *testing.T) {
sourceRoot, _, owner := setupShareArchiveDownloadTest(t)
d := publicMediaShareContext(owner, sourceRoot, share.ShareEditable{
DownloadsLimit: 1,
})
d.Share.Downloads = 1
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=mediashare&path=/movie.mp4&name=movie.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, err := publicSubtitlesHandler(rec, req, d)
if err == nil {
t.Fatal("expected error when download limit is exhausted")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got status=%d err=%v", status, err)
}
}
func TestPublicSubtitlesHandler_BlocksWhenDownloadDisabled(t *testing.T) {
sourceRoot, _, owner := setupShareArchiveDownloadTest(t)
d := publicMediaShareContext(owner, sourceRoot, share.ShareEditable{DisableDownload: true})
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=mediashare&path=/movie.mp4&name=movie.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, err := publicSubtitlesHandler(rec, req, d)
if err == nil {
t.Fatal("expected error when downloads are disabled")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got status=%d err=%v", status, err)
}
}
+14 -5
View File
@@ -70,8 +70,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 {
@@ -406,12 +407,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
}
@@ -493,9 +497,14 @@ func withUserHelper(fn handleFunc) handleFunc {
}
data.User = userValue
// Set cookie. Some clients like gvfs relies on it for concurrent uploads
// Plant the session cookie only when the request didn't already carry
// one. Some clients like gvfs rely on it for concurrent uploads, but a
// request that presented a stale cookie must not re-emit it or it could
// revert the jar to a token retired by a concurrent renew.
if tk.RegisteredClaims.ExpiresAt != nil {
SetSessionCookie(w, r, data.Token, tk.RegisteredClaims.ExpiresAt.Time)
if c, err := r.Cookie(sessionCookieName); err != nil || c.Value == "" || c.Value != data.Token {
SetSessionCookie(w, r, data.Token, tk.RegisteredClaims.ExpiresAt.Time)
}
}
SetUserInResponseWriter(w, data.User)
if data.User.Username == "" {
+2
View File
@@ -18,9 +18,11 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"golang.org/x/crypto/bcrypt"
)
func setupTestEnv(t *testing.T) {
utils.BcryptCost = bcrypt.MinCost
dbPath := filepath.Join(t.TempDir(), "test.sqlite")
// Initialize state with SQLite database
@@ -0,0 +1,104 @@
package web
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
type changeRequiredPasswordBody struct {
Password string `json:"password"`
PasswordConfirm string `json:"passwordConfirm"`
}
// changeRequiredPasswordHandler sets a new password when RequirePasswordChange is enabled, then issues a session token.
// @Summary Change required password at login
// @Description Verifies the current password and sets a new password when requirePasswordChange is set. Issues a JWT on success.
// @Tags Auth
// @Accept json
// @Produce json
// @Param username query string true "Username"
// @Param X-Password header string true "URL-encoded current password"
// @Param X-Secret header string false "TOTP code (if 2FA is enabled)"
// @Param body body changeRequiredPasswordBody true "New password and confirmation"
// @Success 200 {string} string "JWT token for authentication"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 401 {object} map[string]string "Unauthorized"
// @Failure 403 {object} map[string]string "Forbidden"
// @Router /api/auth/password/change-required [post]
func changeRequiredPasswordHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
user, err := auth.AuthenticatePassword(r, false, true)
if err != nil {
if err == errors.ErrNoTotpProvided {
return http.StatusForbidden, err
}
return http.StatusUnauthorized, errors.ErrUnauthorized
}
if settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp && user.TOTPSecret == "" {
return http.StatusForbidden, errors.ErrNoTotpConfigured
}
if user.HasPasskeyMFA() && user.TOTPSecret == "" {
return http.StatusForbidden, errors.ErrPasskeyMFARequired
}
if user.LoginMethod != users.LoginMethodPassword {
return http.StatusBadRequest, fmt.Errorf("password change is only available for password login users")
}
if !user.RequirePasswordChange {
return http.StatusForbidden, fmt.Errorf("password change is not required for this user")
}
var body changeRequiredPasswordBody
if err = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid request body")
}
if body.Password == "" {
return http.StatusBadRequest, errors.ErrEmptyPassword
}
if body.Password != body.PasswordConfirm {
return http.StatusBadRequest, fmt.Errorf("passwords do not match")
}
if err = settings.ValidatePasswordPolicy(body.Password); err != nil {
return http.StatusBadRequest, err
}
currentPassword := r.Header.Get("X-Password")
currentPassword, decErr := url.QueryUnescape(currentPassword)
if decErr != nil {
return http.StatusBadRequest, fmt.Errorf("invalid password encoding")
}
if err = utils.CheckPwd(currentPassword, user.Password); err != nil {
return http.StatusUnauthorized, errors.ErrUnauthorized
}
if body.Password == currentPassword {
return http.StatusBadRequest, fmt.Errorf("new password must differ from the current password")
}
patch := *user
patch.RequirePasswordChange = false
if err = state.UpdateUser(&patch, body.Password, "password", "requirePasswordChange"); err != nil {
return http.StatusInternalServerError, err
}
updated, getErr := state.GetUserByUsername(user.Username)
if getErr != nil {
return http.StatusInternalServerError, getErr
}
d.User = &updated
status, err := printToken(w, r, d.User, "")
if err != nil || status != 0 {
return status, err
}
activity.RecordLogin(r, d.User)
return 0, nil
}
@@ -0,0 +1,88 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestLoginBlocksRequirePasswordChange(t *testing.T) {
setupTestEnv(t)
origPasswordAuth := settings.Config.Auth.Methods.PasswordAuth.Enabled
settings.Config.Auth.Methods.PasswordAuth.Enabled = true
t.Cleanup(func() { settings.Config.Auth.Methods.PasswordAuth.Enabled = origPasswordAuth })
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "mustchange",
LoginMethod: users.LoginMethodPassword,
RequirePasswordChange: true,
},
}
if err := state.CreateUser(user, "tempPass"); err != nil {
t.Fatalf("create user: %v", err)
}
handler := wrapHandler(loginHelper(loginHandler))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login?username=mustchange", nil)
req.Header.Set("X-Password", "tempPass")
handler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("login status = %d, want 403", rec.Code)
}
}
func TestChangeRequiredPasswordHandler(t *testing.T) {
setupTestEnv(t)
origPasswordAuth := settings.Config.Auth.Methods.PasswordAuth.Enabled
settings.Config.Auth.Methods.PasswordAuth.Enabled = true
t.Cleanup(func() { settings.Config.Auth.Methods.PasswordAuth.Enabled = origPasswordAuth })
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "mustchange2",
LoginMethod: users.LoginMethodPassword,
RequirePasswordChange: true,
},
}
if err := state.CreateUser(user, "tempPass"); err != nil {
t.Fatalf("create user: %v", err)
}
body, _ := json.Marshal(map[string]string{
"password": "newSecurePass",
"passwordConfirm": "newSecurePass",
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/password/change-required?username=mustchange2", bytes.NewReader(body))
req.Header.Set("X-Password", "tempPass")
req.Header.Set("Content-Type", "application/json")
handler := wrapHandler(withoutUserHelper(changeRequiredPasswordHandler))
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("change-required status = %d body = %s", rec.Code, rec.Body.String())
}
updated, err := state.GetUserByUsername("mustchange2")
if err != nil {
t.Fatal(err)
}
if updated.RequirePasswordChange {
t.Fatal("expected requirePasswordChange cleared")
}
loginReq := httptest.NewRequest(http.MethodPost, "/?username=mustchange2", nil)
loginReq.Header.Set("X-Password", "newSecurePass")
if _, err = auth.AuthenticatePassword(loginReq, true, false); err != nil {
t.Fatalf("login with new password: %v", err)
}
}
+271
View File
@@ -1,11 +1,15 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"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 TestReplaceSessionTokenKeepsOldTokenWhenMintFails(t *testing.T) {
@@ -50,3 +54,270 @@ func TestReplaceSessionTokenRetiresOldWithGrace(t *testing.T) {
t.Fatal("old token mapping must remain during the grace window")
}
}
// Regression test for #3006: a request that already carried the session cookie
// must not re-emit Set-Cookie. Otherwise an in-flight response carrying a
// just-rotated-out token reverts the browser jar to a token that dies when its
// retirement grace expires.
func TestWithUserDoesNotEchoCookieToken(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "echo-user", users.Permissions{Api: true})
token := testSessionToken(t, user, time.Hour)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/users", http.NoBody)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
withUser(mockHandler)(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
if setCookie := recorder.Header().Values("Set-Cookie"); len(setCookie) != 0 {
t.Fatalf("cookie-carried request must not re-emit Set-Cookie, got %v", setCookie)
}
}
// Clients that authenticate without a session cookie (gvfs, Bearer, ?auth=)
// still get the token planted so subsequent requests stay authenticated.
func TestWithUserPlantsCookieForBearerClients(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "bearer-user", users.Permissions{Api: true})
token := testSessionToken(t, user, time.Hour)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/users", http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
withUser(mockHandler)(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
cookies := recorder.Result().Cookies()
var session *http.Cookie
for _, c := range cookies {
if c.Name == sessionCookieName {
session = c
}
}
if session == nil {
t.Fatal("cookie-less authenticated request must receive Set-Cookie")
}
if session.Value != token {
t.Fatal("planted cookie must contain the presented token")
}
}
func TestRenewHandlerNoopWhenNoAuth(t *testing.T) {
setupTestEnv(t)
orig := settings.Config.Auth.Methods.NoAuth
settings.Config.Auth.Methods.NoAuth = true
t.Cleanup(func() { settings.Config.Auth.Methods.NoAuth = orig })
user := createTokenAuthUser(t, "noauth-renew", users.Permissions{Admin: true})
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/renew", http.NoBody)
status, err := renewHandler(recorder, req, &requestContext{User: user, Token: ""})
if err != nil {
t.Fatalf("renewHandler: %v", err)
}
if status != 0 && status != http.StatusOK {
t.Fatalf("expected success status, got %d", status)
}
}
// Restricted API tokens must not call renew: rotation registers a session hash
// and would grant the token owner's full permissions (GHSA-6gr6-5qpq-888p).
func TestRenewHandlerRejectsApiToken(t *testing.T) {
setupTestEnv(t)
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
user := createTokenAuthUser(t, "renew-api-user", users.Permissions{Admin: true, Api: true})
tokenString, tokenMeta, err := auth.MakeSignedTokenAPI(user, "capped-key", time.Hour, users.Permissions{Api: true}, false)
if err != nil {
t.Fatal(err)
}
tokenMeta.Name = "capped-key"
tokenMeta.Token = tokenString
if err = state.AddUserToken(user.Username, tokenMeta); err != nil {
t.Fatal(err)
}
if err = state.AddApiToken(tokenString, user.ID); err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/renew", http.NoBody)
req.Header.Set("Authorization", "Bearer "+tokenString)
status, err := renewHandler(recorder, req, &requestContext{User: user, Token: tokenString})
if err == nil {
t.Fatal("expected renew to fail for API token")
}
if status != http.StatusForbidden {
t.Fatalf("expected status %d, got %d", http.StatusForbidden, status)
}
if _, isSession, ok := state.HashedTokenOwner(tokenString); !ok || isSession {
t.Fatalf("API token must remain non-session: ok=%v isSession=%v", ok, isSession)
}
}
// Renew while the presented token is still fresh returns it unchanged instead
// of rotating: routine page loads must not churn session-token state.
func TestRenewHandlerFreshTokenIsIdempotent(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "renew-fresh-user", users.Permissions{Api: true})
token := testSessionToken(t, user, 2*time.Hour)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/renew", http.NoBody)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
status, err := renewHandler(recorder, req, &requestContext{User: user, Token: token})
if err != nil {
t.Fatalf("renewHandler: %v", err)
}
if status != 0 && status != http.StatusOK {
t.Fatalf("expected success status, got %d", status)
}
if got := recorder.Body.String(); got != token {
t.Fatal("renew on a fresh token must return the same token")
}
if state.IsTokenRevoked(token) {
t.Fatal("fresh token must not be retired by a no-op renew")
}
if setCookie := recorder.Header().Values("Set-Cookie"); len(setCookie) != 0 {
t.Fatalf("idempotent renew with matching cookie must not re-emit Set-Cookie, got %v", setCookie)
}
}
func TestRenewHandlerFreshTokenPlantsCookieWithoutJar(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "renew-fresh-no-cookie", users.Permissions{Api: true})
token := testSessionToken(t, user, 2*time.Hour)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/renew", http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
status, err := renewHandler(recorder, req, &requestContext{User: user, Token: token})
if err != nil {
t.Fatalf("renewHandler: %v", err)
}
if status != 0 && status != http.StatusOK {
t.Fatalf("expected success status, got %d", status)
}
if len(recorder.Header().Values("Set-Cookie")) == 0 {
t.Fatal("idempotent renew without session cookie must plant Set-Cookie")
}
}
// Concurrent renew ordering: an in-flight idempotent renew must not revert a
// jar that a later rotation already upgraded.
func TestRenewHandlerIdempotentDoesNotRevertRotatedJar(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "renew-order-user", users.Permissions{Api: true})
oldToken := testSessionToken(t, user, 2*time.Hour)
newToken, err := replaceSessionToken(oldToken, user)
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/renew", http.NoBody)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: oldToken})
status, err := renewHandler(recorder, req, &requestContext{User: user, Token: oldToken})
if err != nil {
t.Fatalf("renewHandler: %v", err)
}
if status != 0 && status != http.StatusOK {
t.Fatalf("expected success status, got %d", status)
}
if got := recorder.Body.String(); got != oldToken {
t.Fatal("idempotent renew must still return the presented token")
}
if setCookie := recorder.Header().Values("Set-Cookie"); len(setCookie) != 0 {
t.Fatalf("must not Set-Cookie old token after jar already rotated, got %v", setCookie)
}
if _, isSession, ok := state.HashedTokenOwner(newToken); !ok || !isSession {
t.Fatalf("rotated token must remain registered: ok=%v isSession=%v", ok, isSession)
}
}
func TestWithUserRefreshesCookieWhenBearerMismatchesJar(t *testing.T) {
setupTestEnv(t)
origExp := settings.Config.Auth.TokenExpirationHours
t.Cleanup(func() { settings.Config.Auth.TokenExpirationHours = origExp })
settings.Config.Auth.TokenExpirationHours = 2
user := createTokenAuthUser(t, "bearer-mismatch-user", users.Permissions{Api: true})
stale := testSessionToken(t, user, time.Hour)
fresh, err := replaceSessionToken(stale, user)
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/users", http.NoBody)
req.Header.Set("Authorization", "Bearer "+fresh)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: stale})
withUser(mockHandler)(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d body=%s", recorder.Code, recorder.Body.String())
}
cookies := recorder.Result().Cookies()
var session *http.Cookie
for _, c := range cookies {
if c.Name == sessionCookieName {
session = c
}
}
if session == nil {
t.Fatalf("Bearer auth with stale cookie jar must refresh Set-Cookie, headers=%v", recorder.Header().Values("Set-Cookie"))
}
if session.Value != fresh {
t.Fatal("planted cookie must match the Bearer token")
}
}
// Inside the refresh window renew rotates: new token registered, old retired
// but still resolvable during the grace window.
func TestRenewHandlerNearExpiryRotates(t *testing.T) {
setupTestEnv(t)
origExp := settings.Config.Auth.TokenExpirationHours
t.Cleanup(func() { settings.Config.Auth.TokenExpirationHours = origExp })
settings.Config.Auth.TokenExpirationHours = 2
user := createTokenAuthUser(t, "renew-rotate-user", users.Permissions{Api: true})
token := testSessionToken(t, user, 10*time.Minute)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/renew", http.NoBody)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
status, err := renewHandler(recorder, req, &requestContext{User: user, Token: token})
if err != nil {
t.Fatalf("renewHandler: %v", err)
}
if status != 0 && status != http.StatusOK {
t.Fatalf("expected success status, got %d", status)
}
newToken := recorder.Body.String()
if newToken == token {
t.Fatal("renew inside the refresh window must rotate the token")
}
if _, isSession, ok := state.HashedTokenOwner(newToken); !ok || !isSession {
t.Fatalf("rotated token must be registered as a session: ok=%v isSession=%v", ok, isSession)
}
if state.IsTokenRevoked(token) {
t.Fatal("rotated-out token must remain valid during the grace window")
}
}
+9
View File
@@ -224,6 +224,15 @@ func shareRequestAllowsDownloadToken(r *http.Request) bool {
return shareRouteAllowsDownloadToken(r.URL.Path)
}
// shareRequestAllowsDownload reports metered download routes (not inline view/stream).
func shareRequestAllowsDownload(r *http.Request) bool {
if r == nil {
return false
}
path := r.URL.Path
return strings.Contains(path, "/resources/download") || strings.Contains(path, "/raw")
}
// directDownloadURL builds a public download URL with an ephemeral token query parameter.
func directDownloadURL(host, scheme, hash, token string) string {
tokenParam := ""
+3 -3
View File
@@ -17,7 +17,7 @@ func TestAuthenticateShareRequestUISessionCookie(t *testing.T) {
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.MinCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
@@ -62,7 +62,7 @@ func TestAuthenticateShareRequestDownloadTokenDoesNotMintUISession(t *testing.T)
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.MinCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
@@ -93,7 +93,7 @@ func TestAuthenticateShareRequestDownloadTokenWrongPasswordNoUISession(t *testin
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
passwordHash, bcryptErr := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
passwordHash, bcryptErr := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.MinCost)
if bcryptErr != nil {
t.Fatalf("bcrypt: %v", bcryptErr)
}
+1
View File
@@ -260,6 +260,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,
+69 -4
View File
@@ -232,18 +232,19 @@ func ValidateViewGrant(token string, d *Context, sourceName string) error {
utils.ViewGrantsCache.Delete(token)
return fmt.Errorf("view token expired")
}
if grant.Source != viewGrantScope(d, sourceName) {
viewCtx := contextForViewGrantValidation(d, grant.Source)
if !viewGrantScopeMatches(viewCtx, grant.Source, sourceName) {
return fmt.Errorf("view token scope mismatch")
}
internalSource := sourceName
if d.Share.Hash != "" {
if viewCtx.Share.Hash != "" {
var err error
internalSource, err = shareSourceName(d)
internalSource, err = shareSourceName(viewCtx)
if err != nil {
return err
}
}
perms, err := effectiveFilePerms(d, internalSource)
perms, err := effectiveFilePerms(viewCtx, internalSource)
if err != nil || !perms.View {
return fmt.Errorf("view permission required")
}
@@ -252,6 +253,69 @@ func ValidateViewGrant(token string, d *Context, sourceName string) error {
return nil
}
// contextForViewGrantValidation hydrates share metadata when optional-auth middleware
// left only a hash stub on the context (anonymous public share visitors).
func contextForViewGrantValidation(d *Context, grantSource string) *Context {
if d == nil {
return d
}
if d.Share.Hash != "" {
return d
}
hash := strings.TrimSpace(grantSource)
if hash == "" {
return d
}
link, err := state.GetShare(hash)
if err != nil || link.Hash == "" {
return d
}
out := *d
out.Share = link
if out.ShareUser == nil {
if u, err := state.UserForShareOwner(link); err == nil {
out.ShareUser = &u
}
}
return &out
}
func viewGrantScopeMatches(d *Context, grantSource, sourceName string) bool {
expected := viewGrantScope(d, sourceName)
if grantSource == expected {
return true
}
if d == nil || d.Share.Hash == "" {
return false
}
internalSource, err := shareSourceName(d)
if err != nil {
return false
}
// Accept source-scoped grants on share routes (e.g. minted before share context was hydrated).
return grantSource == internalSource && expected == d.Share.Hash
}
// hydratePublicShareContext ensures public share handlers have full share + owner context.
func hydratePublicShareContext(d *Context, r *http.Request) {
if d == nil || r == nil {
return
}
if d.Share.Hash == "" {
hash := strings.TrimSpace(r.URL.Query().Get("hash"))
if hash != "" {
if link, err := state.GetShare(hash); err == nil {
d.Share = link
}
}
}
if d.Share.Hash != "" && d.ShareUser == nil {
if u, err := state.UserForShareOwner(d.Share); err == nil {
d.ShareUser = &u
}
}
}
func canMintViewToken(d *Context, source string) bool {
if d.Share.Hash != "" && d.Share.ShareType == "upload" {
return false
@@ -583,6 +647,7 @@ func streamHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/api/media/stream [get]
func publicStreamHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
hydratePublicShareContext(d, r)
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("streaming is disabled for upload shares")
}
+27
View File
@@ -801,3 +801,30 @@ func TestViewTokenHandlerExtendsExistingToken(t *testing.T) {
t.Fatalf("expected extended expiry, before=%d after=%d", almostExpired, grantAfter.ExpiresAt)
}
}
func TestValidateViewGrantSourceScopedGrantOnPublicShare(t *testing.T) {
initStreamTestSources(t)
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/srv": {Path: "/srv", Name: "srv"},
}
token, err := utils.RandomHex(16)
if err != nil {
t.Fatal(err)
}
utils.ViewGrantsCache.Set(token, utils.ViewGrant{
Source: "srv",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
})
d := &requestContext{
User: &users.User{
FrontendUser: users.FrontendUser{Username: users.AnonymousUserName},
},
Share: share.Share{
ShareColumns: share.ShareColumns{Hash: "sharehash"},
SourcePath: "/srv",
},
}
if err := ValidateViewGrant(token, d, ""); err != nil {
t.Fatalf("ValidateViewGrant: %v", err)
}
}
+17
View File
@@ -0,0 +1,17 @@
package web
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"golang.org/x/crypto/bcrypt"
)
func TestMain(m *testing.M) {
utils.BcryptCost = bcrypt.MinCost
if err := utils.SetInvalidPasswordHash(); err != nil {
panic(err)
}
os.Exit(m.Run())
}
+52 -20
View File
@@ -6,6 +6,7 @@ import (
"net/url"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
)
@@ -23,14 +24,17 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/otp/generate [post]
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
err := checkPassword(r, d)
if err != nil {
return http.StatusUnauthorized, err
}
user, getErr := state.GetUserByUsername(r.URL.Query().Get("username"))
targetUsername := r.URL.Query().Get("username")
user, getErr := state.GetUserByUsername(targetUsername)
if getErr != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
}
if status, err := requireOtpEnrollmentAuthorized(&user, d); err != nil {
return status, err
}
if err := checkOtpActorPassword(r, d, user.Username); err != nil {
return http.StatusUnauthorized, err
}
url, err := auth.GenerateOtpForUser(&user)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
@@ -60,16 +64,18 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
if code == "" {
return http.StatusUnauthorized, fmt.Errorf("code is required")
}
err := checkPassword(r, d)
if err != nil {
return http.StatusUnauthorized, err
}
user, getErr := state.GetUserByUsername(r.URL.Query().Get("username"))
targetUsername := r.URL.Query().Get("username")
user, getErr := state.GetUserByUsername(targetUsername)
if getErr != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
}
err = auth.VerifyTotpCode(&user, code)
if err != nil {
if status, err := requireOtpEnrollmentAuthorized(&user, d); err != nil {
return status, err
}
if err := checkOtpActorPassword(r, d, user.Username); err != nil {
return http.StatusUnauthorized, err
}
if err := auth.VerifyTotpCode(&user, code); err != nil {
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
}
if err := state.UpdateUser(&user, "", "TOTPSecret", "TOTPNonce", "OtpEnabled"); err != nil {
@@ -83,13 +89,40 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return RenderJSON(w, r, response)
}
func checkPassword(r *http.Request, d *Context) error {
providedPassword := r.Header.Get("X-Password")
username := r.URL.Query().Get("username")
if d.User.Permissions.Admin {
username = d.User.Username
// userConfiguredMFA reports whether the account already has a second factor that must not be reset anonymously.
func userConfiguredMFA(u *users.User) bool {
if u == nil {
return false
}
if u.HasPasskeyMFA() {
return true
}
return u.TOTPSecret != ""
}
// requireOtpEnrollmentAuthorized allows password-only OTP enrollment only for first-time setup (no MFA).
// Replacing or adding TOTP when MFA exists requires an authenticated self or admin session.
func requireOtpEnrollmentAuthorized(target *users.User, d *Context) (int, error) {
if !userConfiguredMFA(target) {
return 0, nil
}
if d.User == nil || d.User.Username == "" || d.User.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("authentication required to reset two-factor authentication")
}
if d.User.Permissions.Admin || d.User.Username == target.Username {
return 0, nil
}
return http.StatusForbidden, fmt.Errorf("not authorized to reset two-factor authentication for this user")
}
// checkOtpActorPassword verifies X-Password for the account that must prove knowledge of the password.
// For admin sessions resetting another user, the admin's password is checked.
func checkOtpActorPassword(r *http.Request, d *Context, targetUsername string) error {
providedPassword := r.Header.Get("X-Password")
actorUsername := targetUsername
if d.User != nil && d.User.Permissions.Admin && d.User.Username != targetUsername {
actorUsername = d.User.Username
}
// url decode
providedPassword, err := url.QueryUnescape(providedPassword)
if err != nil {
return fmt.Errorf("invalid password encoding: %v", err)
@@ -97,14 +130,13 @@ func checkPassword(r *http.Request, d *Context) error {
if providedPassword == "" {
return fmt.Errorf("password is required")
}
user, getErr := state.GetUserByUsername(username)
user, getErr := state.GetUserByUsername(actorUsername)
var passwordHash string
if getErr != nil {
passwordHash = utils.InvalidPasswordHash
} else {
passwordHash = user.Password
}
// always run checkPwd to prevent timing attacks
err = utils.CheckPwd(providedPassword, passwordHash)
if err != nil {
return fmt.Errorf("invalid password or user not found")
+67 -6
View File
@@ -24,7 +24,15 @@ func otpRequest(username, password, code, path string) *http.Request {
return req
}
func TestVerifyOTP_CachedSecretTakesPrecedence(t *testing.T) {
func anonymousContext() *Context {
u := &users.User{
FrontendUser: users.FrontendUser{Username: "anonymous"},
}
state.ApplyUserDefaults(u)
return &Context{User: u}
}
func TestVerifyOTP_CachedSecretTakesPrecedenceWhenAuthenticatedSelf(t *testing.T) {
setupTestEnv(t)
const oldSecret = "SOMEOLDSECRET234"
@@ -41,15 +49,19 @@ func TestVerifyOTP_CachedSecretTakesPrecedence(t *testing.T) {
}
t.Cleanup(func() { auth.TotpCache.Delete(user.Username) })
d := &Context{User: user}
reloaded, reloadErr := state.GetUserByUsername(user.Username)
if reloadErr != nil {
t.Fatalf("failed to reload user: %v", reloadErr)
}
d := &Context{User: &reloaded}
rec := httptest.NewRecorder()
if _, err := generateOTPHandler(rec, otpRequest(user.Username, "testPass", "", "/api/auth/otp/generate"), d); err != nil {
t.Fatalf("generation failed: %v", err)
if _, genErr := generateOTPHandler(rec, otpRequest(user.Username, "testPass", "", "/api/auth/otp/generate"), d); genErr != nil {
t.Fatalf("generation failed: %v", genErr)
}
var resp map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil {
t.Fatalf("failed to decode response: %v", unmarshalErr)
}
u, err := url.Parse(resp["url"])
if err != nil {
@@ -85,6 +97,55 @@ func TestVerifyOTP_CachedSecretTakesPrecedence(t *testing.T) {
}
}
func TestRequireOtpEnrollmentAuthorized(t *testing.T) {
withMFA := &users.User{
FrontendUser: users.FrontendUser{Username: "victim"},
TOTPSecret: "SOMEOLDSECRET234",
}
status, err := requireOtpEnrollmentAuthorized(withMFA, anonymousContext())
if err == nil {
t.Fatal("expected anonymous MFA reset to be rejected")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got %d err=%v", status, err)
}
noMFA := &users.User{
FrontendUser: users.FrontendUser{Username: "new"},
}
if status, err = requireOtpEnrollmentAuthorized(noMFA, anonymousContext()); err != nil {
t.Fatalf("expected first-time enrollment to be allowed: status=%d err=%v", status, err)
}
self := &users.User{
FrontendUser: users.FrontendUser{Username: "victim"},
}
if status, err = requireOtpEnrollmentAuthorized(withMFA, &Context{User: self}); err != nil {
t.Fatalf("expected self reset to be allowed: status=%d err=%v", status, err)
}
admin := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
Permissions: users.Permissions{Admin: true},
},
}
if status, err = requireOtpEnrollmentAuthorized(withMFA, &Context{User: admin}); err != nil {
t.Fatalf("expected admin reset to be allowed: status=%d err=%v", status, err)
}
other := &users.User{
FrontendUser: users.FrontendUser{Username: "other"},
}
status, err = requireOtpEnrollmentAuthorized(withMFA, &Context{User: other})
if err == nil {
t.Fatal("expected non-admin other user to be rejected")
}
if status != http.StatusForbidden {
t.Fatalf("expected 403, got %d err=%v", status, err)
}
}
func TestLogin_RequiresOTPWhenTOTPSecretSet(t *testing.T) {
setupTestEnv(t)
+1
View File
@@ -82,6 +82,7 @@ func viewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/api/resources/view [get]
func PublicViewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
hydratePublicShareContext(d, r)
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("viewing is disabled for upload shares")
}
+19 -6
View File
@@ -9,12 +9,13 @@ import (
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/dbindex"
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/net/webdav"
@@ -89,6 +90,10 @@ type Stats struct {
UsedAsIndexed uint64 `json:"used"`
UsedDisk uint64 `json:"usedAlt"`
DiskTotal uint64 `json:"total"`
// UsedDiskRoot/DiskTotalRoot are the root-filesystem-only view: a single
// statfs of the source path without aggregating nested mounts.
UsedDiskRoot uint64 `json:"usedAltRoot"`
DiskTotalRoot uint64 `json:"totalRoot"`
// UsageScopeMismatch is true when indexed used exceeds partition total at the
// source root (e.g. nested mounts under the source path). Display-only signal.
UsageScopeMismatch bool `json:"usageScopeMismatch,omitempty"`
@@ -1563,15 +1568,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 {
@@ -1670,6 +1677,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{
@@ -1681,6 +1690,8 @@ func (idx *Index) writePersistedIndexInfo() error {
UsedAsIndexed: usedAsIndexed,
UsedDisk: usedDisk,
DiskTotal: diskTotal,
UsedDiskRoot: usedDiskRoot,
DiskTotalRoot: diskTotalRoot,
Scanners: scanners,
}
@@ -1714,6 +1725,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
+5 -3
View File
@@ -6,9 +6,9 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"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-logger/logger"
)
@@ -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()
+15 -3
View File
@@ -24,6 +24,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")
@@ -643,6 +648,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{
@@ -850,6 +859,7 @@ func SetDefaults(generate bool) Settings {
BaseURL: "",
},
Server: Server{
MinSearchLength: defaultMinSearchLength,
NumImageProcessors: 4,
DatabaseV2: Database{
Path: databaseV2,
@@ -887,7 +897,8 @@ func SetDefaults(generate bool) Settings {
},
},
Frontend: Frontend{
Name: "FileBrowser Quantum",
Name: "FileBrowser Quantum",
Description: defaultFrontendDescription,
},
UserDefaults: UserDefaults{
// New organized structure
@@ -954,6 +965,7 @@ func SetDefaults(generate bool) Settings {
Realtime: false,
},
LockPassword: false,
RequirePasswordChange: false,
DisableSettings: false,
LoginMethod: "",
DisableUpdateNotifications: false,
+106
View File
@@ -0,0 +1,106 @@
package settings
import (
"os"
"strconv"
"strings"
)
// expandConfigEnv walks a decoded YAML value tree and substitutes $VAR / ${VAR}
// in string scalars using the process environment. Non-string scalars (numbers,
// booleans, null) are left unchanged so typed config fields stay intact.
// Expansion happens after YAML decode so secrets may contain quotes, backslashes,
// or newlines without breaking the document.
// Maps and slices are mutated in place.
func expandConfigEnv(v interface{}) {
switch x := v.(type) {
case map[string]interface{}:
for k, val := range x {
x[k] = expandConfigEnvValue(val)
}
case []interface{}:
for i, val := range x {
x[i] = expandConfigEnvValue(val)
}
}
}
func expandConfigEnvValue(v interface{}) interface{} {
switch x := v.(type) {
case map[string]interface{}:
expandConfigEnv(x)
return x
case []interface{}:
expandConfigEnv(x)
return x
case string:
return expandConfigEnvString(x)
default:
// int, float, bool, nil, etc.
return v
}
}
// expandConfigEnvString expands environment references in a YAML string scalar.
// When the entire scalar is a single ${VAR} or $VAR reference, the expanded
// value is coerced to bool/int/float when unambiguous so config fields of those
// types keep working (e.g. port: ${PORT}). Partial expansions and ordinary
// strings always remain strings so secrets like "true" or "123" stay safe.
func expandConfigEnvString(s string) interface{} {
if !strings.ContainsAny(s, "$") {
return s
}
expanded := os.ExpandEnv(s)
if isSingleEnvReference(s) {
if coerced, ok := coerceEnvScalar(expanded); ok {
return coerced
}
}
return expanded
}
func isSingleEnvReference(s string) bool {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "${") && strings.HasSuffix(s, "}") {
inner := s[2 : len(s)-1]
return isEnvName(inner)
}
if strings.HasPrefix(s, "$") && !strings.HasPrefix(s, "${") {
return isEnvName(s[1:])
}
return false
}
func isEnvName(name string) bool {
if name == "" {
return false
}
for i, r := range name {
if i == 0 {
if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && r != '_' {
return false
}
continue
}
if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' {
return false
}
}
return true
}
func coerceEnvScalar(s string) (interface{}, bool) {
switch s {
case "true", "True", "TRUE":
return true, true
case "false", "False", "FALSE":
return false, true
}
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
return i, true
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f, true
}
return nil, false
}
+184
View File
@@ -0,0 +1,184 @@
package settings
import (
"os"
"path/filepath"
"testing"
)
func TestExpandConfigEnvStrings(t *testing.T) {
t.Setenv("TEST_EXPAND_ENV_VAR", "from-env")
in := map[string]interface{}{
"password": "${TEST_EXPAND_ENV_VAR}",
"plain": "no-expansion",
"nested": map[string]interface{}{
"value": "$TEST_EXPAND_ENV_VAR",
},
"list": []interface{}{"${TEST_EXPAND_ENV_VAR}", "keep"},
"port": 8080,
"on": true,
}
expandConfigEnv(in)
if in["password"] != "from-env" {
t.Fatalf("password = %#v, want from-env", in["password"])
}
if in["plain"] != "no-expansion" {
t.Fatalf("plain = %#v", in["plain"])
}
nested, ok := in["nested"].(map[string]interface{})
if !ok {
t.Fatalf("nested = %#v, want map", in["nested"])
}
if nested["value"] != "from-env" {
t.Fatalf("nested.value = %#v", nested["value"])
}
list, ok := in["list"].([]interface{})
if !ok {
t.Fatalf("list = %#v, want slice", in["list"])
}
if list[0] != "from-env" || list[1] != "keep" {
t.Fatalf("list = %#v", list)
}
if in["port"] != 8080 {
t.Fatalf("port = %#v, want typed int 8080", in["port"])
}
if in["on"] != true {
t.Fatalf("on = %#v, want typed bool true", in["on"])
}
}
func TestExpandConfigEnvCoercesWholeReference(t *testing.T) {
t.Setenv("TEST_ENV_PORT", "9090")
t.Setenv("TEST_ENV_FLAG", "true")
t.Setenv("TEST_ENV_FLOAT", "1.5")
in := map[string]interface{}{
"port": "${TEST_ENV_PORT}",
"flag": "${TEST_ENV_FLAG}",
"rate": "${TEST_ENV_FLOAT}",
// Partial expansion must stay a string (and not coerce "true"-like secrets).
"msg": "prefix-${TEST_ENV_FLAG}",
}
expandConfigEnv(in)
if in["port"] != int64(9090) {
t.Fatalf("port = %#v (%T), want int64 9090", in["port"], in["port"])
}
if in["flag"] != true {
t.Fatalf("flag = %#v, want bool true", in["flag"])
}
if in["rate"] != 1.5 {
t.Fatalf("rate = %#v, want float64 1.5", in["rate"])
}
if in["msg"] != "prefix-true" {
t.Fatalf("msg = %#v, want string prefix-true", in["msg"])
}
}
func TestConfigLoadYamlEnvSubstitution(t *testing.T) {
testDir := t.TempDir()
const secret = "ldap-bind-secret"
t.Setenv("FILEBROWSER_LDAP_USER_PASSWORD", secret)
configContent := []byte(`
auth:
methods:
ldap:
enabled: true
server: "ldap://ldap.example.com:389"
baseDN: "dc=example,dc=com"
userDN: "cn=admin,dc=example,dc=com"
userPassword: "${FILEBROWSER_LDAP_USER_PASSWORD}"
server:
sources:
- path: "."
`)
configFile := filepath.Join(testDir, "config.yaml")
if err := os.WriteFile(configFile, configContent, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
if err := loadConfigWithDefaults(configFile, true); err != nil {
t.Fatalf("loadConfigWithDefaults: %v", err)
}
if Config.Auth.Methods.LdapAuth.UserPassword != secret {
t.Fatalf("userPassword = %q, want %q", Config.Auth.Methods.LdapAuth.UserPassword, secret)
}
}
func TestConfigLoadYamlEnvSpecialCharacters(t *testing.T) {
tests := []struct {
name string
secret string
}{
{name: "quotes", secret: `p@ss"word'with"quotes`},
{name: "backslashes", secret: `path\to\secret\and\\more`},
{name: "multiline", secret: "line1\nline2\nline3"},
{name: "yaml_special", secret: `a: b # not yaml : [true]`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testDir := t.TempDir()
t.Setenv("FILEBROWSER_LDAP_USER_PASSWORD", tt.secret)
configContent := []byte(`
auth:
methods:
ldap:
enabled: true
server: "ldap://ldap.example.com:389"
baseDN: "dc=example,dc=com"
userDN: "cn=admin,dc=example,dc=com"
userPassword: "${FILEBROWSER_LDAP_USER_PASSWORD}"
server:
sources:
- path: "."
`)
configFile := filepath.Join(testDir, "config.yaml")
if err := os.WriteFile(configFile, configContent, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
if err := loadConfigWithDefaults(configFile, true); err != nil {
t.Fatalf("loadConfigWithDefaults: %v", err)
}
if Config.Auth.Methods.LdapAuth.UserPassword != tt.secret {
t.Fatalf("userPassword = %q, want %q", Config.Auth.Methods.LdapAuth.UserPassword, tt.secret)
}
})
}
}
func TestConfigLoadYamlEnvPreservesTypedScalars(t *testing.T) {
testDir := t.TempDir()
t.Setenv("TEST_HTTP_PORT", "8443")
t.Setenv("TEST_TRUST_PROXY", "true")
configContent := []byte(`
http:
port: ${TEST_HTTP_PORT}
trustProxyHeaders: ${TEST_TRUST_PROXY}
auth:
methods:
password:
enabled: true
server:
sources:
- path: "."
`)
configFile := filepath.Join(testDir, "config.yaml")
if err := os.WriteFile(configFile, configContent, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
if err := loadConfigWithDefaults(configFile, true); err != nil {
t.Fatalf("loadConfigWithDefaults: %v", err)
}
if Config.Http.Port != 8443 {
t.Fatalf("http.port = %d, want 8443", Config.Http.Port)
}
if !Config.Http.TrustProxyHeaders {
t.Fatal("expected http.trustProxyHeaders to be true")
}
}
+17 -1
View File
@@ -15,6 +15,22 @@ var AuthManagedEnforcementPaths = map[string]struct{}{
"account.permissions.admin": {},
}
// TransientAccountEnforcementPaths are enforced defaults that seed account state but must not
// block updates when the user satisfies a one-time requirement (e.g. clearing requirePasswordChange).
var TransientAccountEnforcementPaths = map[string]struct{}{
"account.requirePasswordChange": {},
}
func enforcementPathExcludedFromUserSync(path string) bool {
if _, ok := AuthManagedEnforcementPaths[path]; ok {
return true
}
if _, ok := TransientAccountEnforcementPaths[path]; ok {
return true
}
return false
}
// EnforcementAppliesToUser reports whether user-default enforcement should affect u.
// Admins are exempt; anonymous and nil users are not subject to enforcement.
func EnforcementAppliesToUser(u *users.User) bool {
@@ -30,7 +46,7 @@ func withoutAuthManagedEnforcementPaths(paths map[string]struct{}) map[string]st
}
out := make(map[string]struct{}, len(paths))
for path := range paths {
if _, authManaged := AuthManagedEnforcementPaths[path]; authManaged {
if enforcementPathExcludedFromUserSync(path) {
continue
}
out[path] = struct{}{}
@@ -83,6 +83,39 @@ func TestValidateUserAgainstEnforcedDefaults_skipsAdmin(t *testing.T) {
}
}
func TestValidateUserAgainstEnforcedDefaults_skipsRequirePasswordChange(t *testing.T) {
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "alice",
RequirePasswordChange: false,
},
}
defaults := UserDefaults{Account: UserDefaultsAccount{RequirePasswordChange: true}}
enforced := UserDefaultsEnforcement{
Account: UserDefaultsAccountEnforcement{RequirePasswordChange: true},
}
if err := ValidateUserAgainstEnforcedDefaults(u, defaults, enforced); err != nil {
t.Fatalf("cleared requirePasswordChange should not fail enforced validation: %v", err)
}
}
func TestApplyEnforcedDefaultsFrom_skipsRequirePasswordChange(t *testing.T) {
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "alice",
RequirePasswordChange: false,
},
}
defaults := UserDefaults{Account: UserDefaultsAccount{RequirePasswordChange: true}}
enforced := UserDefaultsEnforcement{
Account: UserDefaultsAccountEnforcement{RequirePasswordChange: true},
}
ApplyEnforcedDefaultsFrom(u, defaults, enforced)
if u.RequirePasswordChange {
t.Fatal("expected requirePasswordChange not re-applied after user cleared it")
}
}
func TestApplyEnforcedDefaultsFrom_onlyEnforcedSubset(t *testing.T) {
u := &users.User{
FrontendUser: users.FrontendUser{
@@ -75,6 +75,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"`
Permissions UserDefaultsAccountPermissionsEnforcement `json:"permissions,omitempty"`
+24
View File
@@ -571,6 +571,18 @@ func BuildNode(v reflect.Value, comm CommentsMap) (*yaml.Node, error) {
return buildNodeWithDefaults(v, comm, reflect.Value{}, SecretFieldsMap{}, DeprecatedFieldsMap{})
}
func conditionalRulesAreUnset(rules []ConditionalRule) bool {
if len(rules) == 0 {
return true
}
for _, rule := range rules {
if rule != (ConditionalRule{}) {
return false
}
}
return true
}
// buildNodeWithDefaults constructs a yaml.Node for any Go value, skipping fields that match defaults, redacting secrets, and filtering deprecated fields
func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.Value, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
// Dereference pointers
@@ -619,6 +631,18 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
currentField := v.Field(i)
// Unset source display names are derived at startup; omit from minimal YAML export.
if defaults.IsValid() && typeName == "Source" && sf.Name == "Name" && currentField.Kind() == reflect.String && currentField.String() == "" {
continue
}
// Empty conditional rules are omitted from minimal YAML (template may include a sample row).
if defaults.IsValid() && typeName == "SourceConfig" && sf.Name == "Rules" {
if rules, ok := currentField.Interface().([]ConditionalRule); ok && conditionalRulesAreUnset(rules) {
continue
}
}
// Check if this is an inline/embedded field
yamlTag := sf.Tag.Get("yaml")
jsonTag := sf.Tag.Get("json")
+22
View File
@@ -493,3 +493,25 @@ func TestGenerateConfigYaml_defaultPermissionsDocumentsView(t *testing.T) {
t.Fatalf("expected defaultPermissions to include view, got excerpt around block")
}
}
func TestGenerateConfigYaml_OmitsEmptyRulesWhenFiltered(t *testing.T) {
t.Chdir(".")
cfg := SetDefaults(true)
cfg.Server.Sources = []*Source{{
Path: "./",
Config: SourceConfig{
DefaultPermissions: BuiltinDefaultSourceFilePermissions(),
},
}}
cfg.Server.DatabaseV2.Path = "./filebrowser.sqlite"
cfg.Server.Logging = []LogConfig{{Output: "stdout", Levels: "info|warning|error"}}
out, err := GenerateConfigYamlWithSource(&cfg, true, false, false, ".")
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "rules:") {
t.Fatalf("expected empty rules to be omitted from filtered YAML, got:\n%s", out)
}
}
+21
View File
@@ -0,0 +1,21 @@
package settings
import "fmt"
// PasswordMinLength returns the configured minimum password length (default 5).
func PasswordMinLength() int {
n := Config.Auth.Methods.PasswordAuth.MinLength
if n < 1 {
return 5
}
return n
}
// ValidatePasswordPolicy checks plaintext passwords against server auth.password settings.
func ValidatePasswordPolicy(password string) error {
min := PasswordMinLength()
if len(password) < min {
return fmt.Errorf("password must be at least %d characters", min)
}
return nil
}
@@ -0,0 +1,24 @@
package settings
import "testing"
func TestValidatePasswordPolicy(t *testing.T) {
orig := Config.Auth.Methods.PasswordAuth.MinLength
defer func() { Config.Auth.Methods.PasswordAuth.MinLength = orig }()
Config.Auth.Methods.PasswordAuth.MinLength = 8
if err := ValidatePasswordPolicy("short"); err == nil {
t.Fatal("expected error for short password")
}
if err := ValidatePasswordPolicy("longenough"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
Config.Auth.Methods.PasswordAuth.MinLength = 0
if PasswordMinLength() != 5 {
t.Fatalf("default min length = %d, want 5", PasswordMinLength())
}
if err := ValidatePasswordPolicy("1234"); err == nil {
t.Fatal("expected default min length 5 to reject 4 chars")
}
}
+1
View File
@@ -402,6 +402,7 @@ type UserDefaultsUI struct {
type UserDefaultsAccount struct {
Permissions UserDefaultsAccountPermissions `json:"permissions"`
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
RequirePasswordChange bool `json:"requirePasswordChange"` // force password change at next password login
DisableSettings bool `json:"disableSettings"` // disable the user from viewing the settings page
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
@@ -64,6 +64,7 @@ func ProfileFromUser(u *users.User) UserProfile {
FileLoading: u.FileLoading,
Account: UserDefaultsAccount{
LockPassword: u.LockPassword,
RequirePasswordChange: u.RequirePasswordChange,
DisableSettings: u.DisableSettings,
DisableUpdateNotifications: u.DisableUpdateNotifications,
Permissions: UserDefaultsAccountPermissions{
@@ -83,6 +84,7 @@ func ExpandProfileIntoUser(u *users.User, p UserProfile) {
}
u.DisableSettings = p.Account.DisableSettings
u.LockPassword = p.Account.LockPassword
u.RequirePasswordChange = p.Account.RequirePasswordChange
u.DisableUpdateNotifications = p.Account.DisableUpdateNotifications
u.Permissions.Api = p.Account.Permissions.Api
u.Permissions.Admin = p.Account.Permissions.Admin
+111
View File
@@ -770,6 +770,87 @@ const docTemplate = `{
]
}
},
"/api/auth/password/change-required": {
"post": {
"description": "Verifies the current password and sets a new password when requirePasswordChange is set. Issues a JWT on success.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Change required password at login",
"parameters": [
{
"type": "string",
"description": "Username",
"name": "username",
"in": "query",
"required": true
},
{
"type": "string",
"description": "URL-encoded current password",
"name": "X-Password",
"in": "header",
"required": true
},
{
"type": "string",
"description": "TOTP code (if 2FA is enabled)",
"name": "X-Secret",
"in": "header"
},
{
"description": "New password and confirmation",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/web.changeRequiredPasswordBody"
}
}
],
"responses": {
"200": {
"description": "JWT token for authentication",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/auth/renew": {
"post": {
"description": "Refresh the authentication token for a logged-in user.",
@@ -799,6 +880,15 @@ const docTemplate = `{
}
}
},
"403": {
"description": "Forbidden - non-session token",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -6480,6 +6570,10 @@ const docTemplate = `{
},
"permissions": {
"$ref": "#/definitions/settings.UserDefaultsAccountPermissions"
},
"requirePasswordChange": {
"description": "force password change at next password login",
"type": "boolean"
}
}
},
@@ -7391,6 +7485,9 @@ const docTemplate = `{
"description": "show icon to download in one click",
"type": "boolean"
},
"requirePasswordChange": {
"type": "boolean"
},
"scopes": {
"type": "array",
"items": {
@@ -7785,6 +7882,9 @@ const docTemplate = `{
"description": "show icon to download in one click",
"type": "boolean"
},
"requirePasswordChange": {
"type": "boolean"
},
"scopes": {
"type": "array",
"items": {
@@ -8164,6 +8264,17 @@ const docTemplate = `{
}
}
},
"web.changeRequiredPasswordBody": {
"type": "object",
"properties": {
"password": {
"type": "string"
},
"passwordConfirm": {
"type": "string"
}
}
},
"web.duplicateGroup": {
"type": "object",
"properties": {
+111
View File
@@ -759,6 +759,87 @@
]
}
},
"/api/auth/password/change-required": {
"post": {
"description": "Verifies the current password and sets a new password when requirePasswordChange is set. Issues a JWT on success.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Change required password at login",
"parameters": [
{
"type": "string",
"description": "Username",
"name": "username",
"in": "query",
"required": true
},
{
"type": "string",
"description": "URL-encoded current password",
"name": "X-Password",
"in": "header",
"required": true
},
{
"type": "string",
"description": "TOTP code (if 2FA is enabled)",
"name": "X-Secret",
"in": "header"
},
{
"description": "New password and confirmation",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/web.changeRequiredPasswordBody"
}
}
],
"responses": {
"200": {
"description": "JWT token for authentication",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/auth/renew": {
"post": {
"description": "Refresh the authentication token for a logged-in user.",
@@ -788,6 +869,15 @@
}
}
},
"403": {
"description": "Forbidden - non-session token",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -6469,6 +6559,10 @@
},
"permissions": {
"$ref": "#/definitions/settings.UserDefaultsAccountPermissions"
},
"requirePasswordChange": {
"description": "force password change at next password login",
"type": "boolean"
}
}
},
@@ -7380,6 +7474,9 @@
"description": "show icon to download in one click",
"type": "boolean"
},
"requirePasswordChange": {
"type": "boolean"
},
"scopes": {
"type": "array",
"items": {
@@ -7774,6 +7871,9 @@
"description": "show icon to download in one click",
"type": "boolean"
},
"requirePasswordChange": {
"type": "boolean"
},
"scopes": {
"type": "array",
"items": {
@@ -8153,6 +8253,17 @@
}
}
},
"web.changeRequiredPasswordBody": {
"type": "object",
"properties": {
"password": {
"type": "string"
},
"passwordConfirm": {
"type": "string"
}
}
},
"web.duplicateGroup": {
"type": "object",
"properties": {
+75
View File
@@ -1017,6 +1017,9 @@ definitions:
type: string
permissions:
$ref: '#/definitions/settings.UserDefaultsAccountPermissions'
requirePasswordChange:
description: force password change at next password login
type: boolean
type: object
settings.UserDefaultsAccountPermissions:
properties:
@@ -1653,6 +1656,8 @@ definitions:
quickDownload:
description: show icon to download in one click
type: boolean
requirePasswordChange:
type: boolean
scopes:
items:
$ref: '#/definitions/users.FrontendScope'
@@ -1943,6 +1948,8 @@ definitions:
quickDownload:
description: show icon to download in one click
type: boolean
requirePasswordChange:
type: boolean
scopes:
items:
$ref: '#/definitions/users.FrontendScope'
@@ -2212,6 +2219,13 @@ definitions:
default: fromSource). Example: "backups"'
type: string
type: object
web.changeRequiredPasswordBody:
properties:
password:
type: string
passwordConfirm:
type: string
type: object
web.duplicateGroup:
properties:
count:
@@ -2832,6 +2846,61 @@ paths:
summary: Verify OTP
tags:
- Auth
/api/auth/password/change-required:
post:
consumes:
- application/json
description: Verifies the current password and sets a new password when requirePasswordChange
is set. Issues a JWT on success.
parameters:
- description: Username
in: query
name: username
required: true
type: string
- description: URL-encoded current password
in: header
name: X-Password
required: true
type: string
- description: TOTP code (if 2FA is enabled)
in: header
name: X-Secret
type: string
- description: New password and confirmation
in: body
name: body
required: true
schema:
$ref: '#/definitions/web.changeRequiredPasswordBody'
produces:
- application/json
responses:
"200":
description: JWT token for authentication
schema:
type: string
"400":
description: Bad request
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
summary: Change required password at login
tags:
- Auth
/api/auth/renew:
post:
consumes:
@@ -2850,6 +2919,12 @@ paths:
additionalProperties:
type: string
type: object
"403":
description: Forbidden - non-session token
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
+1
View File
@@ -222,6 +222,7 @@ userDefaults: # optional signup/CLI defaults; per-us
share: false # allow sharing files
realtime: false # allow realtime updates
lockPassword: false # disable the user from changing their password
requirePasswordChange: false # force password change at next password login
disableSettings: false # disable the user from viewing the settings page
loginMethod: "password" # login method to use: eg. password, proxy, oidc
disableUpdateNotifications: false # disable update notifications banner for admin users
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<defs>
<pattern id="halloween-pattern" width="200" height="200" patternUnits="userSpaceOnUse">
<rect width="200" height="200" fill="#ececec"/>
<g opacity="0.09" fill="#1a1a1a">
<!-- Ghost 1 (top-left) - straight sides ghost -->
<path d="M 60 60 A 15 15 0 1 1 90 60 L 90 90 C 80 100, 70 100, 60 90 L 60 60 Z" />
<circle cx="70" cy="55" r="2" fill="#ececec"/>
<circle cx="80" cy="55" r="2" fill="#ececec"/>
<!-- Ghost 2 (bottom-right, offset for diagonal pattern) - straight sides ghost -->
<path d="M 160 160 A 15 15 0 1 1 190 160 L 190 190 C 180 200, 170 200, 160 190 L 160 160 Z" />
<circle cx="170" cy="155" r="2" fill="#ececec"/>
<circle cx="180" cy="155" r="2" fill="#ececec"/>
</g>
</pattern>
</defs>
<rect width="200" height="200" fill="url(#halloween-pattern)"/>
</svg>

After

Width:  |  Height:  |  Size: 940 B

+34
View File
@@ -2,6 +2,40 @@ import { fetchJSON, fetchURL } from '@/api/utils'
import { notify } from '@/notify'
import { getApiPath } from '@/utils/url.js'
// POST /api/auth/password/change-required
export async function changeRequiredPassword(username, currentPassword, newPassword, otp) {
if (!otp) {
otp = "";
}
const params = { username };
const apiPath = getApiPath("auth/password/change-required", params);
const res = await fetch(apiPath, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-Password": encodeURIComponent(currentPassword),
"X-Secret": otp,
},
body: JSON.stringify({
password: newPassword,
passwordConfirm: newPassword,
}),
});
const bodyText = await res.text();
let body;
try {
body = JSON.parse(bodyText);
} catch {
body = { message: bodyText };
}
if (res.status !== 200) {
const msg = body.message || "Forbidden";
throw new Error(msg);
}
}
// POST /api/auth/login
export async function login(username, password, recaptcha, otp) {
if (!otp) {
@@ -0,0 +1,113 @@
<template>
<div class="halloween-lightning" aria-hidden="true">
<div class="lightning-flash" :class="{ 'lightning-flash--active': flashActive }" />
<svg
v-for="bolt in visibleBolts"
:key="bolt.id"
class="lightning-bolt lightning-bolt--strike"
:style="bolt.style"
:viewBox="bolt.viewBox"
xmlns="http://www.w3.org/2000/svg"
>
<path class="lightning-bolt-glow" :d="bolt.main" />
<path
v-for="(branch, index) in bolt.branches"
:key="`${bolt.id}-b-${index}`"
class="lightning-bolt-branch"
:d="branch"
/>
<path class="lightning-bolt-core" :d="bolt.main" />
</svg>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
import {
createLightningStrike,
prefersReducedMotion,
type LightningStrike,
} from "@/utils/lightning-bolt";
type Bolt = LightningStrike & { id: number };
type Timer = ReturnType<typeof setTimeout>;
const STRIKE_VISIBLE_MS = 480;
const FIRST_STRIKE_DELAY_MS = 2000;
const MIN_INTERVAL_MS = 3500;
const MAX_INTERVAL_MS = 11000;
const flashActive = ref(false);
const visibleBolts = shallowRef<Bolt[]>([]);
let nextBoltId = 0;
let scheduleTimer: Timer | null = null;
let flashTimers: Timer[] = [];
function clearSchedule() {
if (scheduleTimer != null) {
clearTimeout(scheduleTimer);
scheduleTimer = null;
}
}
function clearFlashTimers() {
for (const id of flashTimers) clearTimeout(id);
flashTimers = [];
}
function scheduleStrike(delayMs: number) {
clearSchedule();
scheduleTimer = setTimeout(() => {
triggerStrike();
scheduleNextStrike();
}, delayMs);
}
function scheduleNextStrike() {
const delay =
MIN_INTERVAL_MS + Math.random() * (MAX_INTERVAL_MS - MIN_INTERVAL_MS);
scheduleStrike(delay);
}
function triggerStrike() {
const strike = createLightningStrike();
const id = nextBoltId++;
visibleBolts.value = [{ id, ...strike }];
runFlashSequence();
setTimeout(() => {
visibleBolts.value = visibleBolts.value.filter((b) => b.id !== id);
}, STRIKE_VISIBLE_MS);
}
function runFlashSequence() {
clearFlashTimers();
const setFlash = (on: boolean) => {
flashActive.value = on;
};
const schedule = (ms: number, fn: () => void) => {
flashTimers.push(setTimeout(fn, ms));
};
setFlash(true);
schedule(70, () => setFlash(false));
schedule(130, () => setFlash(true));
schedule(210, () => setFlash(false));
schedule(280, () => setFlash(true));
schedule(360, () => setFlash(false));
}
onMounted(() => {
if (prefersReducedMotion()) return;
scheduleStrike(FIRST_STRIKE_DELAY_MS);
});
onBeforeUnmount(() => {
clearSchedule();
clearFlashTimers();
visibleBolts.value = [];
});
</script>
@@ -0,0 +1,85 @@
<template>
<ul v-if="visible" class="password-requirements" aria-live="polite">
<li :class="{ met: state.passwordsMatch, unmet: state.showMismatch }">
<i class="material-symbols-outlined" aria-hidden="true">{{ matchIcon }}</i>
{{ $t("settings.passwordRequirementsMatch") }}
</li>
<li :class="{ met: state.minLengthMet, unmet: state.showLengthHint && !state.minLengthMet }">
<i class="material-symbols-outlined" aria-hidden="true">{{ minLengthIcon }}</i>
{{ $t("settings.passwordRequirementsMinLength", { min: state.minLength }) }}
</li>
</ul>
</template>
<script>
import { evaluatePasswordPolicy } from "@/utils/passwordPolicy.js";
const requirementMetIcon = "check_circle";
const requirementUnmetIcon = "cancel";
export default {
name: "PasswordRequirementsHint",
props: {
password: {
type: String,
default: "",
},
confirmPassword: {
type: String,
default: "",
},
/** When false, hide until the user types in either field. */
showWhenEmpty: {
type: Boolean,
default: false,
},
},
computed: {
state() {
return evaluatePasswordPolicy(this.password, this.confirmPassword);
},
visible() {
if (this.showWhenEmpty) {
return true;
}
return (
String(this.password ?? "").length > 0 || String(this.confirmPassword ?? "").length > 0
);
},
matchIcon() {
return this.state.passwordsMatch ? requirementMetIcon : requirementUnmetIcon;
},
minLengthIcon() {
return this.state.minLengthMet ? requirementMetIcon : requirementUnmetIcon;
},
},
};
</script>
<style scoped>
.password-requirements {
list-style: none;
padding: 0;
margin: 0.25em 0 0.75em;
font-size: 0.9rem;
}
.password-requirements li {
display: flex;
align-items: center;
gap: 0.35em;
color: var(--textSecondary, #666);
}
.password-requirements li.met {
color: var(--green, #2e7d32);
}
.password-requirements li.unmet {
color: var(--red, #c62828);
}
.password-requirements i {
font-size: 1.1em;
}
</style>
+25 -5
View File
@@ -11,7 +11,8 @@
</div>
<ul v-if="effectiveNodes?.length" class="tree-list">
<li v-for="node in effectiveNodes" :key="node.path" class="tree-item">
<div
<a
:href="getNodeUrl(node)"
class="tree-node"
:class="{
'current-item': isCurrentItem(node),
@@ -19,7 +20,7 @@
'drag-over': node.dragOver || isSelected(node),
'hidden-file': node.isHidden,
}"
@click="handleNodeClick(node)"
@click="onNodeClick($event, node)"
@contextmenu.prevent="handleContextMenu($event, node)"
@dragover.prevent="handleDragOver($event, node)"
@dragleave.prevent="handleDragLeave($event, node)"
@@ -29,7 +30,7 @@
<span
v-if="node.isDir"
class="expand-icon"
@click.stop="toggleExpand(node, true)"
@click.stop.prevent="toggleExpand(node, true)"
>
<i class="material-symbols">
{{ node.expanded ? 'expand_more' : 'chevron_right' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
@@ -45,7 +46,7 @@
<span class="node-name" :title="node.name">
{{ node.name }}
</span>
</div>
</a>
<!-- Recursive children if expanded -->
<FileTree
v-if="node.expanded && node.children && node.children.length > 0"
@@ -80,7 +81,7 @@ import Icon from '@/components/files/Icon.vue';
import LoadingSpinner from '@/components/LoadingSpinner.vue';
import { state, getters, mutations } from '@/store';
import { eventBus } from '@/store/eventBus';
import { goToItem, joinPath } from '@/utils/url';
import { buildItemUrl, goToItem, joinPath } from '@/utils/url';
import { goToItemNotificationButton } from '@/utils/notificationActions';
import { notify } from '@/notify';
import { getObjectProperty } from '@/utils/object.js';
@@ -313,6 +314,23 @@ export default {
this.pendingExpandPath = null;
},
getNodeUrl(node) {
const source = node.source || this.currentSource;
return buildItemUrl(source, node.path, true);
},
/** @param {MouseEvent} event */
onNodeClick(event, node) {
if (event.button !== 0) {
return;
}
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
this.handleNodeClick(node);
},
handleNodeClick(node) {
// don't navigate to current item if we're already viewing it
if (this.isCurrentItem(node)) {
@@ -708,6 +726,8 @@ export default {
line-height: 1.4;
width: 100%;
box-sizing: border-box;
text-decoration: none;
color: inherit;
}
.tree-node.hidden-file {
+1 -12
View File
@@ -1,5 +1,5 @@
<template>
<div class="scroll-wrapper" :class="{ 'halloween-theme': eventTheme === 'halloween' }" :style="halloweenBackgroundStyle" ref="wrapper">
<div class="scroll-wrapper" :class="{ 'halloween-theme': eventTheme === 'halloween' }" ref="wrapper">
<slot />
<div
class="custom-scrollbar"
@@ -33,8 +33,6 @@
<script>
import { state, mutations, getters } from "@/store";
import { globalVars } from "@/utils/constants";
const offsetFromBottomListing = 110;
const offsetFromBottomFull = 75;
@@ -68,15 +66,6 @@ export default {
eventTheme() {
return getters.eventTheme();
},
halloweenBackgroundStyle() {
if (this.eventTheme === 'halloween') {
return {
backgroundImage: `url("${globalVars.baseURL}static/img/halloween-pattern.svg")`,
backgroundRepeat: 'repeat'
};
}
return {};
},
thumbIcon() {
const category = this.category();
if (category === 'pinned') return 'push_pin';
@@ -97,6 +97,7 @@ import IconPicker from "./IconPicker.vue";
import Sidebar from "../sidebar/Sidebar.vue";
import UserEdit from "./UserEdit.vue";
import Totp from "./Totp.vue";
import RequirePasswordChange from "./RequirePasswordChange.vue";
import Access from "./Access.vue";
import Password from "./Password.vue";
import PlaybackQueue from "./PlaybackQueue.vue";
@@ -137,6 +138,7 @@ export default {
Replace,
ReplaceRename,
Totp,
RequirePasswordChange,
Upload,
Sidebar,
CreateApi,
@@ -375,6 +377,8 @@ export default {
return this.$t("share.shareInfo");
case "totp":
return this.$t("otp.name");
case "requirepasswordchange":
return this.$t("login.requirePasswordChangeTitle");
case "useredit":
return this.$t("settings.modifyOtherUser");
case "deleteuser":
@@ -0,0 +1,187 @@
<template>
<div class="card-content">
<div v-if="error !== ''" class="wrong-login card">{{ error }}</div>
<p>{{ $t("login.requirePasswordChangeInstructions") }}</p>
<label for="new-password">{{ $t("general.password") }}</label>
<input
id="new-password"
v-focus
class="input"
:class="{ 'form-invalid': showFieldInvalid }"
type="password"
autocomplete="new-password"
v-model="newPassword"
:placeholder="$t('settings.enterPassword')"
/>
<input
class="input"
:class="{ 'form-invalid': showFieldInvalid }"
type="password"
autocomplete="new-password"
v-model="passwordConfirm"
:placeholder="$t('settings.enterPasswordAgain')"
@keydown.enter.prevent="submit"
/>
<PasswordRequirementsHint :password="newPassword" :confirm-password="passwordConfirm" />
<template v-if="needsOtpInput">
<label for="require-change-otp">{{ $t("otp.codeInputPlaceholder") }}</label>
<input
id="require-change-otp"
class="input"
:class="{ 'form-invalid': showFieldInvalid }"
type="text"
autocomplete="one-time-code"
v-model="otp"
@keydown.enter.prevent="submit"
/>
</template>
</div>
<div class="card-actions">
<button
type="button"
class="button button--flat button--blue"
:disabled="submitInFlight || !canSubmit"
@click="submit"
>
{{ $t("general.update") }}
</button>
</div>
</template>
<script>
import { mutations } from "@/store";
import { notify } from "@/notify";
import { authApi } from "@/api";
import { initAuth } from "@/utils/auth";
import { evaluatePasswordPolicy } from "@/utils/passwordPolicy.js";
import PasswordRequirementsHint from "@/components/PasswordRequirementsHint.vue";
export default {
name: "requirePasswordChange",
components: {
PasswordRequirementsHint,
},
props: {
redirect: {
type: String,
default: "",
},
username: {
type: String,
default: "",
},
password: {
type: String,
default: "",
},
/** When true, show the TOTP field immediately (user already uses 2FA). */
otpRequired: {
type: Boolean,
default: false,
},
},
data() {
return {
error: "",
newPassword: "",
passwordConfirm: "",
otp: "",
submitInFlight: false,
showOtpField: false,
};
},
computed: {
needsOtpInput() {
return this.otpRequired || this.showOtpField;
},
passwordPolicy() {
return evaluatePasswordPolicy(this.newPassword, this.passwordConfirm);
},
showFieldInvalid() {
if (this.error !== "") {
return true;
}
const p = this.passwordPolicy;
return p.showMismatch || (p.showLengthHint && !p.minLengthMet);
},
canSubmit() {
if (
String(this.password ?? "").trim() === "" ||
!this.passwordPolicy.valid ||
this.newPassword === this.password
) {
return false;
}
if (this.needsOtpInput && String(this.otp ?? "").trim() === "") {
return false;
}
return true;
},
},
watch: {
newPassword() {
this.error = "";
},
passwordConfirm() {
this.error = "";
},
},
methods: {
otpRequiredForMessage(message) {
const text = String(message ?? "");
return text.includes("OTP code is required for user");
},
async submit(event) {
event?.preventDefault?.();
if (this.submitInFlight || !this.canSubmit) {
return;
}
if (!this.passwordPolicy.valid) {
this.error = this.$t("settings.passwordRequirementsMinLength", {
min: this.passwordPolicy.minLength,
});
notify.showError(this.error);
return;
}
if (this.newPassword === this.password) {
this.error = this.$t("login.requirePasswordChangeMustDiffer");
notify.showError(this.error);
return;
}
this.submitInFlight = true;
try {
await authApi.changeRequiredPassword(this.username, this.password, this.newPassword, this.otp);
await initAuth();
const path = this.redirect !== "" ? this.redirect : "/files/";
await this.$router.push(path);
notify.showSuccessToast(this.$t("login.requirePasswordChangeSuccess"));
mutations.closeTopPrompt();
} catch (err) {
const message = err.message || this.$t("login.failedLogin");
if (this.otpRequiredForMessage(message)) {
this.showOtpField = true;
}
this.error = message;
notify.showError(this.error);
} finally {
this.submitInFlight = false;
}
},
},
};
</script>
<style scoped>
.wrong-login {
background: var(--red) !important;
color: #fff;
padding: 0.5em;
text-align: center;
margin-bottom: 0.5em;
}
.input {
margin-bottom: 0.5em;
}
</style>
@@ -138,6 +138,14 @@
@update:model-value="updateUsageTextMode"
/>
</div>
<!-- Root-filesystem-only disk usage toggle (any bar shows disk total as max) -->
<ToggleSwitch class="item"
v-if="showIndexedUsage || showDiskUsage"
:modelValue="rootFilesystemOnly"
@update:modelValue="updateRootFilesystemOnly"
:name="$t('sidebar.rootFilesystemOnly')"
:description="$t('sidebar.rootFilesystemOnlyDescription')" />
</div>
</div>
@@ -297,6 +305,7 @@ import { notify } from "@/notify";
import { shareApi } from "@/api";
import { tools } from "@/utils/constants";
import { getIconClass } from "@/utils/material-symbols";
import { baseSidebarCategory, isRootOnlySidebarCategory, isSourceSidebarCategory, withRootOnlySuffix } from "@/utils/sidebarCategory";
import { getObjectProperty } from '@/utils/object.js';
import FileList from "../files/FileList.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
@@ -382,13 +391,20 @@ export default {
return this.newLink.target && this.newLink.name;
},
showIndexedUsage() {
return this.newLink.category === 'source' || this.newLink.category === 'source-hybrid' || this.newLink.category === 'source-hybrid-2';
const base = baseSidebarCategory(this.newLink.category);
return base === 'source' || base === 'source-hybrid' || base === 'source-hybrid-2';
},
showDiskUsage() {
return this.newLink.category === 'source-alt' || this.newLink.category === 'source-hybrid' || this.newLink.category === 'source-hybrid-2';
const base = baseSidebarCategory(this.newLink.category);
return base === 'source-alt' || base === 'source-hybrid' || base === 'source-hybrid-2';
},
rootFilesystemOnly() {
// Default (off) = aggregate all filesystems under the source; on = only
// the filesystem containing the source path.
return isRootOnlySidebarCategory(this.newLink.category);
},
usageTextMode() {
if (this.newLink.category === 'source-hybrid-2') {
if (baseSidebarCategory(this.newLink.category) === 'source-hybrid-2') {
return 'disk';
}
return 'indexed';
@@ -565,7 +581,7 @@ export default {
return defaultLinks;
},
isSourceCategory(category) {
return category === 'source' || category === 'source-minimal' || category === 'source-alt' || category === 'source-hybrid' || category === 'source-hybrid-2';
return isSourceSidebarCategory(category);
},
updateUsageToggles(toggleType, value) {
// Determine the new category based on toggle states
@@ -573,34 +589,34 @@ export default {
// indexed=false, disk=true -> 'source-alt'
// indexed=true, disk=true -> 'source-hybrid' or 'source-hybrid-2' (depends on usageTextMode)
// indexed=false, disk=false -> 'source-minimal'
// The root-only "-root" suffix is preserved across all variants.
const indexed = toggleType === 'indexed' ? value : this.showIndexedUsage;
const disk = toggleType === 'disk' ? value : this.showDiskUsage;
const base = baseSidebarCategory(this.newLink.category);
let category;
if (indexed && disk) {
// Preserve the hybrid mode variant if it was already set
if (this.newLink.category === 'source-hybrid-2') {
this.newLink.category = 'source-hybrid-2';
} else {
this.newLink.category = 'source-hybrid';
}
category = base === 'source-hybrid-2' ? 'source-hybrid-2' : 'source-hybrid';
} else if (indexed && !disk) {
this.newLink.category = 'source';
category = 'source';
} else if (!indexed && disk) {
this.newLink.category = 'source-alt';
category = 'source-alt';
} else {
this.newLink.category = 'source-minimal';
category = 'source-minimal';
}
this.newLink.category = withRootOnlySuffix(category, this.rootFilesystemOnly);
},
updateUsageTextMode(mode) {
if (mode === "disk") {
this.newLink.category = 'source-hybrid-2';
} else {
this.newLink.category = 'source-hybrid';
}
const category = mode === "disk" ? 'source-hybrid-2' : 'source-hybrid';
this.newLink.category = withRootOnlySuffix(category, this.rootFilesystemOnly);
},
updateRootFilesystemOnly(value) {
this.newLink.category = withRootOnlySuffix(this.newLink.category, value);
},
getCategoryLabel(category) {
switch (category) {
switch (baseSidebarCategory(category)) {
case 'source':
case 'source-minimal':
case 'source-alt':
+24 -3
View File
@@ -78,6 +78,10 @@ export default {
type: String,
default: "",
},
recaptcha: {
type: String,
default: "",
},
},
async mounted() {
if (this.generate) {
@@ -124,12 +128,29 @@ export default {
}
this.verifyInFlight = true;
try {
await authApi.verifyOTP(this.username, this.password, this.code);
if (this.redirect !== "") {
await authApi.login(this.username, this.password, this.redirect, this.code);
const isLoginFlow = this.redirect !== "";
if (isLoginFlow && !this.generate) {
await authApi.login(
this.username,
this.password,
this.recaptcha,
this.code,
);
await initAuth();
// Wait for navigation so Layout mounts and registers the toast callback before we toast
await this.$router.push(this.redirect);
} else {
await authApi.verifyOTP(this.username, this.password, this.code);
if (isLoginFlow) {
await authApi.login(
this.username,
this.password,
this.recaptcha,
this.code,
);
await initAuth();
await this.$router.push(this.redirect);
}
}
void mutations.updateCurrentUser({ otpEnabled: true });
this.succeeded = true;
@@ -19,6 +19,7 @@
:enforced="enforced.account || {}"
:enforced-permissions="enforced.account?.permissions || {}"
:config-locked-paths="lockedFromConfigPaths"
:show-require-password-change="passwordAccountDefaults"
@account-change="onAccountFieldChange"
@enforced-change="(field, value) => patchEnforcedFlag('account', field, value)"
@enforced-permission-change="(field, value) => patchEnforcedFlag('account', `permissions.${field}`, value)"
@@ -78,6 +79,10 @@ export default {
};
},
computed: {
passwordAccountDefaults() {
const method = this.values.account?.loginMethod;
return !method || method === "password";
},
preferenceSections: {
get() {
const v = this.values;
+39 -10
View File
@@ -26,6 +26,7 @@
{{ $t("general.update") }}
</button>
</div>
<PasswordRequirementsHint :password="passwordRef" :confirm-password="user.password" />
<div style="display: flex; flex-direction: column">
<div class="settings-items">
<ToggleSwitch class="item" v-model="user.otpEnabled" :name="$t('otp.name')" />
@@ -84,6 +85,7 @@
{{ $t("general.update") }}
</button>
</div>
<PasswordRequirementsHint :password="passwordRef" :confirm-password="user.password" />
</div>
<div style="padding-bottom: 1em" v-if="stateUser.permissions.admin">
@@ -143,6 +145,7 @@
:account="editAccount"
:enforced="enforcedAccount"
:enforced-permissions="enforcedAccountPermissions"
:show-require-password-change="user.loginMethod === 'password'"
respect-enforced-policy
@account-change="onEditAccountChange"
/>
@@ -185,7 +188,9 @@ import SettingsItem from "@/components/settings/SettingsItem.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import UserDefaultsAccountSection from "@/components/settings/UserDefaultsAccountSection.vue";
import UserProfilePreferences from "@/components/settings/UserProfilePreferences.vue";
import PasswordRequirementsHint from "@/components/PasswordRequirementsHint.vue";
import Errors from "@/views/Errors.vue";
import { evaluatePasswordPolicy } from "@/utils/passwordPolicy.js";
import { notify } from "@/notify";
import { validateLogin } from "@/utils/auth";
import { globalVars } from "@/utils/constants";
@@ -239,6 +244,7 @@ export default {
ToggleSwitch,
UserDefaultsAccountSection,
UserProfilePreferences,
PasswordRequirementsHint,
Errors,
},
props: {
@@ -280,6 +286,7 @@ export default {
sourceFilePermissionDefaults: null,
editAccount: {
lockPassword: false,
requirePasswordChange: false,
disableSettings: false,
disableUpdateNotifications: false,
permissions: {
@@ -323,18 +330,18 @@ export default {
});
},
invalidPassword() {
const matching =
this.user.password !== this.passwordRef && this.user.password.length > 0;
return matching;
},
/** Update is allowed only when both password fields are non-empty (trimmed) and match. */
canUpdatePassword() {
const a = String(this.passwordRef ?? "").trim();
const b = String(this.user.password ?? "").trim();
if (a.length === 0 || b.length === 0) {
const p = this.passwordPolicy;
if (String(this.passwordRef ?? "").length === 0 && String(this.user.password ?? "").length === 0) {
return false;
}
return !this.invalidPassword;
return !p.valid;
},
passwordPolicy() {
return evaluatePasswordPolicy(this.passwordRef, this.user.password);
},
/** Update is allowed only when both password fields match and meet server policy. */
canUpdatePassword() {
return this.passwordPolicy.valid;
},
passwordAvailable: () => globalVars.passwordAvailable,
globalVars: () => globalVars,
@@ -570,6 +577,7 @@ export default {
if (user.account && typeof user.account === "object") {
const account = user.account;
user.lockPassword = !!account.lockPassword;
user.requirePasswordChange = !!account.requirePasswordChange;
user.disableSettings = !!account.disableSettings;
user.disableUpdateNotifications = !!account.disableUpdateNotifications;
if (account.loginMethod) {
@@ -771,6 +779,7 @@ export default {
loginMethod: this.user.loginMethod ?? null,
otpEnabled: !!this.user.otpEnabled,
lockPassword: !!this.user.lockPassword,
requirePasswordChange: !!this.user.requirePasswordChange,
disableSettings: !!this.user.disableSettings,
disableUpdateNotifications: !!this.user.disableUpdateNotifications,
permissions: {
@@ -801,6 +810,12 @@ export default {
if (current.lockPassword !== orig.lockPassword && !this.enforcedAccount.lockPassword) {
fields.push("lockPassword");
}
if (
current.requirePasswordChange !== orig.requirePasswordChange
&& !this.enforcedAccount.requirePasswordChange
) {
fields.push("requirePasswordChange");
}
if (current.disableSettings !== orig.disableSettings && !this.enforcedAccount.disableSettings) {
fields.push("disableSettings");
}
@@ -835,6 +850,7 @@ export default {
syncEditAccountForm() {
const p = this.user.permissions || {};
this.editAccount.lockPassword = !!this.user.lockPassword;
this.editAccount.requirePasswordChange = !!this.user.requirePasswordChange;
this.editAccount.disableSettings = !!this.user.disableSettings;
this.editAccount.disableUpdateNotifications = !!this.user.disableUpdateNotifications;
this.editAccount.permissions = {
@@ -846,6 +862,7 @@ export default {
},
applyEditAccountToUser() {
this.user.lockPassword = this.editAccount.lockPassword;
this.user.requirePasswordChange = this.editAccount.requirePasswordChange;
this.user.disableSettings = this.editAccount.disableSettings;
this.user.disableUpdateNotifications = this.editAccount.disableUpdateNotifications;
if (!this.user.permissions) {
@@ -910,6 +927,18 @@ export default {
notify.showError(this.$t("settings.userNotAdmin"));
return;
}
if (
payload.loginMethod === "password"
&& this.globalVars.passwordAvailable
&& !this.passwordPolicy.valid
) {
notify.showError(
this.$t("settings.passwordRequirementsMinLength", {
min: this.passwordPolicy.minLength,
}),
);
return;
}
await usersApi.create(
payload,
{
@@ -12,6 +12,18 @@
:value-tooltip="fieldDisabledTooltip('lockPassword')"
:name="$t('settings.lockPassword')"
/>
<ToggleSwitch
v-if="showRequirePasswordChange"
class="item"
:enforceable="enforceable"
:enforced="!!enforced.requirePasswordChange"
v-model="account.requirePasswordChange"
@change="$emit('account-change', 'requirePasswordChange')"
@update:enforced="(v) => emitEnforced('requirePasswordChange', v)"
:disabled="isFieldDisabled('requirePasswordChange')"
:value-tooltip="fieldDisabledTooltip('requirePasswordChange')"
:name="$t('settings.requirePasswordChange')"
/>
<ToggleSwitch
class="item"
:enforceable="enforceable"
@@ -127,6 +139,10 @@ export default {
type: Boolean,
default: false,
},
showRequirePasswordChange: {
type: Boolean,
default: false,
},
},
emits: ["account-change", "enforced-change", "enforced-permission-change"],
methods: {
+45 -27
View File
@@ -81,24 +81,24 @@
info
</i>
</div>
<div v-if="hasUsageInfo(link) && link.category !== 'source-minimal'" class="usage-info">
<div v-if="hasUsageInfo(link) && baseSidebarCategory(link.category) !== 'source-minimal'" class="usage-info">
<!-- For source-hybrid, show single bar with background value for disk usage -->
<ProgressBar
v-if="link.category === 'source-hybrid' || link.category === 'source-hybrid-2'"
:key="`progress-hybrid-${link.sourceName}-${sourceInfo[link.sourceName]?.used || 0}-${sourceInfo[link.sourceName]?.usedAlt || 0}-${sourceInfo[link.sourceName]?.total || 0}`"
<ProgressBar
v-if="isHybridCategory(link.category)"
:key="`progress-hybrid-${link.sourceName}-${sourceInfo[link.sourceName]?.used || 0}-${getDiskUsed(link, sourceInfo[link.sourceName] || {})}-${getUsageTotal(link, sourceInfo[link.sourceName] || {})}`"
:val="sourceInfo[link.sourceName]?.used || 0"
:val-background="sourceInfo[link.sourceName]?.usedAlt || 0"
:val-text="link.category === 'source-hybrid-2' ? (sourceInfo[link.sourceName]?.usedAlt || 0) : null"
:max="sourceInfo[link.sourceName]?.total || 1"
:val-background="getDiskUsed(link, sourceInfo[link.sourceName] || {})"
:val-text="baseSidebarCategory(link.category) === 'source-hybrid-2' ? getDiskUsed(link, sourceInfo[link.sourceName] || {}) : null"
:max="getUsageTotal(link, sourceInfo[link.sourceName] || {})"
:status="getProgressBarStatus(link, sourceInfo[link.sourceName] || {})"
unit="bytes">
</ProgressBar>
<!-- For other source types, show single bar -->
<ProgressBar
<ProgressBar
v-else
:key="`progress-${link.sourceName}-${sourceInfo[link.sourceName]?.used || 0}-${sourceInfo[link.sourceName]?.usedAlt || 0}-${sourceInfo[link.sourceName]?.total || 0}`"
:val="getProgressBarValue(link, sourceInfo[link.sourceName] || {})"
:max="sourceInfo[link.sourceName]?.total || 1"
:key="`progress-${link.sourceName}-${sourceInfo[link.sourceName]?.used || 0}-${getDiskUsed(link, sourceInfo[link.sourceName] || {})}-${getUsageTotal(link, sourceInfo[link.sourceName] || {})}`"
:val="getProgressBarValue(link, sourceInfo[link.sourceName] || {})"
:max="getUsageTotal(link, sourceInfo[link.sourceName] || {})"
:status="getProgressBarStatus(link, sourceInfo[link.sourceName] || {})"
unit="bytes">
</ProgressBar>
@@ -164,19 +164,19 @@
</i>
</div>
<div v-if="hasUsageInfo(activeSourceLink)" class="usage-info">
<ProgressBar
v-if="activeSourceLink.category === 'source-hybrid' || activeSourceLink.category === 'source-hybrid-2'"
<ProgressBar
v-if="isHybridCategory(activeSourceLink.category)"
:val="(activeSourceInfo).used || 0"
:val-background="(activeSourceInfo).usedAlt || 0"
:val-text="activeSourceLink.category === 'source-hybrid-2' ? ((activeSourceInfo).usedAlt || 0) : null"
:max="(activeSourceInfo).total || 1"
:val-background="getDiskUsed(activeSourceLink, activeSourceInfo)"
:val-text="baseSidebarCategory(activeSourceLink.category) === 'source-hybrid-2' ? getDiskUsed(activeSourceLink, activeSourceInfo) : null"
:max="getUsageTotal(activeSourceLink, activeSourceInfo)"
:status="getProgressBarStatus(activeSourceLink, activeSourceInfo)"
unit="bytes">
</ProgressBar>
<ProgressBar
<ProgressBar
v-else
:val="getProgressBarValue(activeSourceLink, activeSourceInfo)"
:max="(activeSourceInfo).total || 1"
:val="getProgressBarValue(activeSourceLink, activeSourceInfo)"
:max="getUsageTotal(activeSourceLink, activeSourceInfo)"
:status="getProgressBarStatus(activeSourceLink, activeSourceInfo)"
unit="bytes">
</ProgressBar>
@@ -215,6 +215,7 @@ import {
import ProgressBar from "@/components/ProgressBar.vue";
import { goToItem } from "@/utils/url";
import { getIconClass } from "@/utils/material-symbols";
import { baseSidebarCategory, isRootOnlySidebarCategory, isSourceSidebarCategory } from "@/utils/sidebarCategory";
import { getObjectProperty } from '@/utils/object.js';
import IndexInfo from "@/components/files/IndexInfo.vue";
import { globalVars } from "@/utils/constants";
@@ -357,7 +358,24 @@ export default {
},
methods: {
isSourceCategory(category) {
return category.startsWith("source")
return isSourceSidebarCategory(category);
},
isHybridCategory(category) {
const base = baseSidebarCategory(category);
return base === 'source-hybrid' || base === 'source-hybrid-2';
},
baseSidebarCategory,
/** Disk capacity for the link's scope: aggregated nested mounts (default) or root filesystem only (-root categories). */
getUsageTotal(link, sourceInfo) {
const root = isRootOnlySidebarCategory(link.category);
const total = root ? (sourceInfo.totalRoot || 0) : (sourceInfo.total || 0);
return total > 0 ? total : (sourceInfo.total || 1);
},
/** Disk-reported used bytes for the link's scope. */
getDiskUsed(link, sourceInfo) {
return isRootOnlySidebarCategory(link.category)
? (sourceInfo.usedAltRoot || 0)
: (sourceInfo.usedAlt || 0);
},
getIconClass,
hasUsageInfo(link) {
@@ -365,7 +383,7 @@ export default {
// Returns true when link is accessible and has usage > 0
if (!this.isSourceCategory(link.category) || !link.sourceName) return false;
if (!this.hasSourceInfo || !this.isLinkAccessible(link)) return false;
if (link.category === 'source-minimal') return false;
if (baseSidebarCategory(link.category) === 'source-minimal') return false;
const info = this.sourceInfo[link.sourceName] || {};
return (info.used || 0) > 0 || (info.usedAlt || 0) > 0;
},
@@ -470,21 +488,21 @@ export default {
},
hasUsageScopeMismatch(link, sourceInfo) {
if (!link || !sourceInfo) return false;
const cat = link.category;
if (cat !== 'source' && cat !== 'source-hybrid' && cat !== 'source-hybrid-2') {
const base = baseSidebarCategory(link.category);
if (base !== 'source' && base !== 'source-hybrid' && base !== 'source-hybrid-2') {
return false;
}
if (sourceInfo.usageScopeMismatch) {
if (sourceInfo.usageScopeMismatch && !isRootOnlySidebarCategory(link.category)) {
return true;
}
const used = Number(sourceInfo.used) || 0;
const total = Number(sourceInfo.total) || 0;
const total = Number(this.getUsageTotal(link, sourceInfo)) || 0;
return total > 0 && used > total;
},
getProgressBarValue(link, sourceInfo) {
// Called with (link, sourceInfo) from both modes
if (link.category === 'source-alt') {
return sourceInfo.usedAlt || 0;
if (baseSidebarCategory(link.category) === 'source-alt') {
return this.getDiskUsed(link, sourceInfo);
}
return sourceInfo.used || 0;
},
+1 -1
View File
@@ -202,7 +202,7 @@ export default {
transform: translateZ(0);
height: 100%;
transition: 0.4s ease;
top: 4em;
top: var(--header-height);
padding-bottom: 4em;
background-color: color-mix(in srgb, var(--alt-background) 20%, transparent);
will-change: left;
File diff suppressed because it is too large. Load diff
+12 -3
View File
@@ -145,7 +145,11 @@
"wrongCredentials": "بيانات دخول خاطئة",
"failedLogin": "فشل في تسجيل الدخول",
"invalidCredentials": "تم توفير بيانات اعتماد غير صالحة",
"passkeyLogin": "استخدام مفتاح المرور"
"passkeyLogin": "استخدام مفتاح المرور",
"requirePasswordChangeTitle": "تغيير كلمة المرور",
"requirePasswordChangeInstructions": "يجب عليك تعيين كلمة مرور جديدة قبل المتابعة.",
"requirePasswordChangeSuccess": "تم تحديث كلمة المرور. لقد تم تسجيل دخولك الآن.",
"requirePasswordChangeMustDiffer": "يجب أن تكون كلمة المرور الجديدة مختلفة عن كلمة المرور الحالية"
},
"prompts": {
"deleteMessageSingle": "هل تريد بالتأكيد حذف هذا الملف/المجلد؟",
@@ -304,7 +308,10 @@
"accountDefaults": "التخلف عن سداد الالتزامات المتعلقة بالحساب",
"disableUserSettings": "تعطيل صفحة إعدادات المستخدم",
"userDefaultFieldLockedFromConfig": "يتم تعيين هذا الإعداد الافتراضي في ملف التكوين ولا يمكن تغييره هنا.",
"sourceAccessDefaultsHelp": "يتم تطبيق أذونات الملفات الافتراضية على المصادر والمستخدمين الجدد. ويؤدي خيار «Enforce» إلى تقييد القيمة لجميع المستخدمين غير الإداريين."
"sourceAccessDefaultsHelp": "يتم تطبيق أذونات الملفات الافتراضية على المصادر والمستخدمين الجدد. ويؤدي خيار «Enforce» إلى تقييد القيمة لجميع المستخدمين غير الإداريين.",
"passwordRequirementsMatch": "الكلمات المرور متطابقة",
"passwordRequirementsMinLength": "{min} حرفًا على الأقل",
"requirePasswordChange": "طلب تغيير كلمة المرور عند تسجيل الدخول التالي"
},
"sidebar": {
"updateIsAvailable": "يتوفر تحديث!",
@@ -345,7 +352,9 @@
"usageTextDisk": "الاستخدام كما تم الإبلاغ عنه بواسطة القرص/القسم",
"dividerNamePlaceholder": "نص المقسم (اتركه فارغًا لإظهار سطر)",
"showIndexedUsage": "عرض الاستخدام حسب الفهرس",
"showDiskUsage": "عرض الاستخدام وفقًا لما أورده القرص/القسم"
"showDiskUsage": "عرض الاستخدام وفقًا لما أورده القرص/القسم",
"rootFilesystemOnly": "تقييد استخدام القرص بنظام الملفات المصدر",
"rootFilesystemOnlyDescription": "عند تمكين هذه الميزة، يتم الإبلاغ فقط عن نظام الملفات الذي يحتوي على مسار المصدر. وعند تعطيلها (الإعداد الافتراضي)، يشمل إجمالي سعة القرص أيضًا أنظمة الملفات المثبتة تحت مسار المصدر، مع احتساب المجموعات المشتركة مثل مجموعات بيانات ZFS مرة واحدة فقط."
},
"share": {
"notice": "ملاحظة: عندما يتم إنشاء مشاركة، تصبح أكثر انكشافًا للجمهور ولها قواعد مختلفة للوصول إليها. إذا لم يتم تهيئة مشاركتك بشكل آمن، فيمكن لأي شخص الوصول إليها.",
+12 -3
View File
@@ -309,7 +309,11 @@
"wrongCredentials": "Грешни данни за вход",
"failedLogin": "Неуспешно вписване",
"invalidCredentials": "Предоставени са невалидни данни за вход",
"passkeyLogin": "Използване на Passkey"
"passkeyLogin": "Използване на Passkey",
"requirePasswordChangeTitle": "Променете паролата си",
"requirePasswordChangeInstructions": "Трябва да зададете нова парола, преди да продължите.",
"requirePasswordChangeSuccess": "Паролата е актуализирана. Вече сте влезли в системата.",
"requirePasswordChangeMustDiffer": "Новата парола трябва да е различна от настоящата ви парола"
},
"share": {
"notice": "Забележка: когато споделянето е създадено, то става по-достъпно за обществеността и има различни правила за достъп. Ако вашето споделяне не е сигурно конфигурирано, всеки може да получи достъп до него.",
@@ -618,7 +622,10 @@
"accountDefaults": "Настройки по подразбиране на акаунта",
"disableUserSettings": "Деактивиране на страницата с потребителските настройки",
"userDefaultFieldLockedFromConfig": "Тази настройка по подразбиране е зададена във файла с конфигурацията и не може да бъде променена тук.",
"sourceAccessDefaultsHelp": "Прилагат се стандартните права за достъп до файлове за изходните файлове и новите потребители. Опцията „Enforce“ фиксира определена стойност за всички потребители, които не са администратори."
"sourceAccessDefaultsHelp": "Прилагат се стандартните права за достъп до файлове за изходните файлове и новите потребители. Опцията „Enforce“ фиксира определена стойност за всички потребители, които не са администратори.",
"passwordRequirementsMatch": "Паролите съвпадат",
"passwordRequirementsMinLength": "Най-малко {min} символа",
"requirePasswordChange": "Да се изисква промяна на паролата при следващото влизане"
},
"time": {
"time": "{prefix}Време{suffix}",
@@ -670,7 +677,9 @@
"linksUpdateFailed": "Неуспешно обновяване на връзките в страничната лента",
"sourceNotAccessible": "Този източник вече не е достъпен за вас",
"noLinksYet": "Все още няма конфигурирани връзки. Добавете първата си връзка по-долу.",
"switchMode": "Смяна на режима на страничната лента"
"switchMode": "Смяна на режима на страничната лента",
"rootFilesystemOnly": "Ограничаване на използването на дисково пространство до изходната файлова система",
"rootFilesystemOnlyDescription": "Когато тази опция е активирана, се отчита само файловата система, съдържаща изходния път. Когато е деактивирана (по подразбиране), общият обем на диска включва и файловите системи, монтирани под изходния път, като споделените пулове, например набори от данни в ZFS, се отчитат само веднъж."
},
"api": {
"title": "API токени",
+12 -3
View File
@@ -145,7 +145,11 @@
"wrongCredentials": "Nesprávné přihlašovací údaje",
"failedLogin": "Nepodařilo se přihlásit",
"invalidCredentials": "Neplatné zadané pověření",
"passkeyLogin": "Použít Passkey"
"passkeyLogin": "Použít Passkey",
"requirePasswordChangeTitle": "Změňte si heslo",
"requirePasswordChangeInstructions": "Než budete pokračovat, musíte nastavit nové heslo.",
"requirePasswordChangeSuccess": "Heslo bylo aktualizováno. Nyní jste přihlášeni.",
"requirePasswordChangeMustDiffer": "Nové heslo se nesmí shodovat s vaším současným heslem"
},
"share": {
"notice": "Poznámka: po vytvoření sdílené složky je tato složka více vystavena veřejnosti a platí pro ni jiná pravidla přístupu. Pokud není sdílená složka bezpečně nakonfigurována, může k ní mít přístup kdokoli.",
@@ -454,7 +458,10 @@
"accountDefaults": "Výchozí nastavení účtu",
"disableUserSettings": "Zakázat stránku uživatelských nastavení",
"userDefaultFieldLockedFromConfig": "Toto výchozí nastavení je definováno v konfiguračním souboru a zde jej nelze změnit.",
"sourceAccessDefaultsHelp": "Výchozí oprávnění k souborům se vztahují na zdrojové soubory a nové uživatele. Nastavení „Vynutit“ určuje pevnou hodnotu pro všechny uživatele, kteří nejsou správci."
"sourceAccessDefaultsHelp": "Výchozí oprávnění k souborům se vztahují na zdrojové soubory a nové uživatele. Nastavení „Vynutit“ určuje pevnou hodnotu pro všechny uživatele, kteří nejsou správci.",
"passwordRequirementsMatch": "Hesla se shodují",
"passwordRequirementsMinLength": "Nejméně {min} ových znaků",
"requirePasswordChange": "Vyžadovat změnu hesla při příštím přihlášení"
},
"sidebar": {
"updateIsAvailable": "Je dostupná aktualizace!",
@@ -495,7 +502,9 @@
"usageTextDisk": "využití podle hlášení disku/oddílu",
"dividerNamePlaceholder": "Text oddělovače (pro zobrazení řádku nechte prázdný)",
"showIndexedUsage": "Zobrazit použití podle indexu",
"showDiskUsage": "Zobrazit využití podle údajů o disku/oddílu"
"showDiskUsage": "Zobrazit využití podle údajů o disku/oddílu",
"rootFilesystemOnly": "Omezit využití disku na zdrojový souborový systém",
"rootFilesystemOnlyDescription": "Je-li tato možnost zapnutá, vykazuje se pouze souborový systém obsahující zdrojovou cestu. Je-li vypnutá (výchozí nastavení), celková velikost disku zahrnuje také souborové systémy připojené pod zdrojovou cestou, přičemž sdílené fondy, jako jsou datové sady ZFS, se počítají pouze jednou."
},
"api": {
"createdAt": "Vytvořeno:",
+12 -3
View File
@@ -145,7 +145,11 @@
"wrongCredentials": "Falsche Zugangsdaten",
"failedLogin": "Anmeldung fehlgeschlagen",
"invalidCredentials": "Ungültige Anmeldedaten angegeben",
"passkeyLogin": "Passkey verwenden"
"passkeyLogin": "Passkey verwenden",
"requirePasswordChangeTitle": "Passwort ändern",
"requirePasswordChangeInstructions": "Sie müssen ein neues Passwort festlegen, bevor Sie fortfahren können.",
"requirePasswordChangeSuccess": "Das Passwort wurde aktualisiert. Sie sind nun angemeldet.",
"requirePasswordChangeMustDiffer": "Das neue Passwort muss sich von Ihrem aktuellen Passwort unterscheiden."
},
"prompts": {
"deleteMessageSingle": "Sind Sie sicher, dass Sie diesen Ordner/diese Datei löschen möchten?",
@@ -304,7 +308,10 @@
"accountDefaults": "Standardwerte für Konten",
"disableUserSettings": "Seite „Benutzereinstellungen“ deaktivieren",
"userDefaultFieldLockedFromConfig": "Diese Standardeinstellung wird in der Konfigurationsdatei festgelegt und kann hier nicht geändert werden.",
"sourceAccessDefaultsHelp": "Standard-Dateiberechtigungen für Quellen und neue Benutzer. Mit „Enforce“ wird ein Wert für alle Benutzer ohne Administratorrechte festgelegt."
"sourceAccessDefaultsHelp": "Standard-Dateiberechtigungen für Quellen und neue Benutzer. Mit „Enforce“ wird ein Wert für alle Benutzer ohne Administratorrechte festgelegt.",
"passwordRequirementsMatch": "Die Passwörter stimmen überein",
"passwordRequirementsMinLength": "Mindestens {min} Zeichen",
"requirePasswordChange": "Passwortwechsel bei der nächsten Anmeldung erzwingen"
},
"sidebar": {
"updateIsAvailable": "Ein Update ist verfügbar!",
@@ -345,7 +352,9 @@
"usageTextDisk": "Nutzung, wie von der Festplatte/Partition gemeldet",
"dividerNamePlaceholder": "Teilertext (leer lassen, um eine Zeile anzuzeigen)",
"showIndexedUsage": "Verwendung gemäß Index anzeigen",
"showDiskUsage": "Nutzung nach Festplatte/Partition anzeigen"
"showDiskUsage": "Nutzung nach Festplatte/Partition anzeigen",
"rootFilesystemOnly": "Festplattennutzung auf das Quelldateisystem beschränken",
"rootFilesystemOnlyDescription": "Wenn diese Option aktiviert ist, wird nur das Dateisystem gemeldet, das den Quellpfad enthält. Wenn sie deaktiviert ist (Standardeinstellung), umfasst die Gesamtgröße der Festplatte auch die unter dem Quellpfad eingebundenen Dateisysteme, wobei gemeinsam genutzte Pools wie ZFS-Datensätze nur einmal gezählt werden."
},
"share": {
"notice": "Hinweis: Wenn eine Freigabe erstellt wird, ist sie der Öffentlichkeit stärker ausgesetzt und unterliegt anderen Zugriffsregeln. Wenn Ihre Freigabe nicht sicher konfiguriert ist, kann jeder auf sie zugreifen.",
+12 -3
View File
@@ -145,7 +145,11 @@
"wrongCredentials": "Λάθος όνομα ή/και κωδικός πρόσβασης",
"failedLogin": "Απέτυχε η σύνδεση",
"invalidCredentials": "Παρέχονται άκυρα διαπιστευτήρια",
"passkeyLogin": "Χρήση Passkey"
"passkeyLogin": "Χρήση Passkey",
"requirePasswordChangeTitle": "Αλλάξτε τον κωδικό πρόσβασής σας",
"requirePasswordChangeInstructions": "Πρέπει να ορίσετε έναν νέο κωδικό πρόσβασης πριν συνεχίσετε.",
"requirePasswordChangeSuccess": "Ο κωδικός πρόσβασης ενημερώθηκε. Έχετε πλέον συνδεθεί.",
"requirePasswordChangeMustDiffer": "Ο νέος κωδικός πρόσβασης πρέπει να διαφέρει από τον τρέχοντα κωδικό πρόσβασής σας"
},
"prompts": {
"deleteMessageSingle": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το αρχείο/φάκελο;",
@@ -304,7 +308,10 @@
"accountDefaults": "Προεπιλογές λογαριασμού",
"disableUserSettings": "Απενεργοποίηση της σελίδας ρυθμίσεων χρήστη",
"userDefaultFieldLockedFromConfig": "Αυτή η προεπιλογή ορίζεται στο αρχείο ρυθμίσεων και δεν μπορεί να τροποποιηθεί εδώ.",
"sourceAccessDefaultsHelp": "Εφαρμόζονται τα προεπιλεγμένα δικαιώματα αρχείων στις πηγές και στους νέους χρήστες. Η επιλογή «Επιβολή» καθορίζει μια συγκεκριμένη τιμή για όλους τους χρήστες που δεν είναι διαχειριστές."
"sourceAccessDefaultsHelp": "Εφαρμόζονται τα προεπιλεγμένα δικαιώματα αρχείων στις πηγές και στους νέους χρήστες. Η επιλογή «Επιβολή» καθορίζει μια συγκεκριμένη τιμή για όλους τους χρήστες που δεν είναι διαχειριστές.",
"passwordRequirementsMatch": "Οι κωδικοί πρόσβασης ταιριάζουν",
"passwordRequirementsMinLength": "Τουλάχιστον {min} χαρακτήρες",
"requirePasswordChange": "Απαιτείται αλλαγή κωδικού πρόσβασης κατά την επόμενη σύνδεση"
},
"sidebar": {
"updateIsAvailable": "Μια ενημέρωση είναι διαθέσιμη!",
@@ -345,7 +352,9 @@
"usageTextDisk": "χρήση όπως αναφέρεται από δίσκο/κατάτμηση",
"dividerNamePlaceholder": "Κείμενο διαχωριστικού (αφήστε το κενό για να εμφανιστεί μια γραμμή)",
"showIndexedUsage": "Εμφάνιση χρήσης σύμφωνα με το ευρετήριο",
"showDiskUsage": "Εμφάνιση χρήσης όπως καταγράφεται ανά δίσκο/διαμέρισμα"
"showDiskUsage": "Εμφάνιση χρήσης όπως καταγράφεται ανά δίσκο/διαμέρισμα",
"rootFilesystemOnly": "Περιορισμός της χρήσης χώρου στο δίσκο στο σύστημα αρχείων προέλευσης",
"rootFilesystemOnlyDescription": "Όταν είναι ενεργοποιημένη, αναφέρεται μόνο το σύστημα αρχείων που περιέχει τη διαδρομή προέλευσης. Όταν είναι απενεργοποιημένη (προεπιλογή), το συνολικό μέγεθος του δίσκου περιλαμβάνει επίσης τα συστήματα αρχείων που έχουν συνδεθεί κάτω από τη διαδρομή προέλευσης, ενώ οι κοινόχρηστες ομάδες, όπως τα σύνολα δεδομένων ZFS, υπολογίζονται μία φορά."
},
"share": {
"notice": "Σημείωση: όταν δημιουργείται μια κοινή χρήση, γίνεται πιο εκτεθειμένη στο κοινό και έχει διαφορετικούς κανόνες πρόσβασης. Εάν η κοινή σας χρήση δεν έχει ρυθμιστεί με ασφάλεια, μπορεί να έχει πρόσβαση ο καθένας.",
+10 -1
View File
@@ -309,7 +309,11 @@
"wrongCredentials": "Wrong credentials",
"failedLogin": "Failed to login",
"invalidCredentials": "Invalid credentials provided",
"passkeyLogin": "Use Passkey"
"passkeyLogin": "Use Passkey",
"requirePasswordChangeTitle": "Change your password",
"requirePasswordChangeInstructions": "You must set a new password before continuing.",
"requirePasswordChangeSuccess": "Password updated. You are now signed in.",
"requirePasswordChangeMustDiffer": "New password must be different from your current password"
},
"share": {
"notice": "Note: when a share is a created, it becomes more exposed to the public and has different rules for access. If your share is not securely configured, it may be accessed by anyone.",
@@ -592,6 +596,8 @@
"analyticsPreviewFailed": "Failed to load analytics diagnostic info",
"enterPassword": "Enter password",
"enterPasswordAgain": "Enter the password again",
"passwordRequirementsMatch": "Passwords match",
"passwordRequirementsMinLength": "At least {min} characters",
"loginMethod": "Login method",
"loginMethods": {
"password": "Password"
@@ -607,6 +613,7 @@
"userNotAdmin": "User is not an administrator",
"avoidChanges": "also tick 'change password' to apply this password",
"lockPassword": "Prevent the user from changing the password",
"requirePasswordChange": "Require password change at next login",
"permissions": {
"admin": "Administrator",
"api": "Create and manage long-live API Tokens",
@@ -651,6 +658,8 @@
"usageTextDisk": "usage as reported by disk/partition",
"showIndexedUsage": "Show usage as indexed",
"showDiskUsage": "Show usage as reported by disk/partition",
"rootFilesystemOnly": "Limit disk usage to source filesystem",
"rootFilesystemOnlyDescription": "When enabled, only the filesystem containing the source path is reported. When disabled (default), the disk total also includes filesystems mounted under the source path, with shared pools like ZFS datasets counted once.",
"selectSource": "Select Source",
"chooseSource": "Choose a source",
"selectShare": "Select Share",
+12 -3
View File
@@ -145,7 +145,11 @@
"wrongCredentials": "Nombre de usuario y/o contraseña incorrectos",
"failedLogin": "Error al iniciar sesión",
"invalidCredentials": "Credenciales no válidas",
"passkeyLogin": "Usar clave de acceso"
"passkeyLogin": "Usar clave de acceso",
"requirePasswordChangeTitle": "Cambia tu contraseña",
"requirePasswordChangeInstructions": "Debes establecer una nueva contraseña antes de continuar.",
"requirePasswordChangeSuccess": "Se ha actualizado la contraseña. Ya has iniciado sesión.",
"requirePasswordChangeMustDiffer": "La nueva contraseña debe ser diferente de tu contraseña actual"
},
"prompts": {
"deleteMessageSingle": "¿Estás seguro que quieres borrar este archivo?",
@@ -304,7 +308,10 @@
"accountDefaults": "Configuración predeterminada de la cuenta",
"disableUserSettings": "Desactivar la página de configuración del usuario",
"userDefaultFieldLockedFromConfig": "Este valor por defecto se establece en el archivo de configuración y no se puede modificar aquí.",
"sourceAccessDefaultsHelp": "Permisos predeterminados de los archivos aplicados a las fuentes y a los nuevos usuarios. La opción «Aplicar» fija un valor para todos los usuarios que no sean administradores."
"sourceAccessDefaultsHelp": "Permisos predeterminados de los archivos aplicados a las fuentes y a los nuevos usuarios. La opción «Aplicar» fija un valor para todos los usuarios que no sean administradores.",
"passwordRequirementsMatch": "Las contraseñas coinciden",
"passwordRequirementsMinLength": "Al menos {min} caracteres",
"requirePasswordChange": "Exigir el cambio de contraseña en el próximo inicio de sesión"
},
"sidebar": {
"updateIsAvailable": "¡Actualización disponible!",
@@ -345,7 +352,9 @@
"usageTextDisk": "uso por disco/partición",
"showIndexedUsage": "Mostrar uso indexado",
"showDiskUsage": "Mostrar uso del disco/partición",
"dividerNamePlaceholder": "Separador visual (déjalo en blanco para mostrar una línea)"
"dividerNamePlaceholder": "Separador visual (déjalo en blanco para mostrar una línea)",
"rootFilesystemOnly": "Limitar el uso del disco al sistema de archivos de origen",
"rootFilesystemOnlyDescription": "Cuando está activada, solo se incluye en el informe el sistema de archivos que contiene la ruta de origen. Cuando está desactivada (por defecto), el total del disco también incluye los sistemas de archivos montados bajo la ruta de origen, y los conjuntos de datos compartidos, como los de ZFS, se cuentan una sola vez."
},
"share": {
"notice": "Cuando se crea un recurso compartido, este quedará más expuesto al público y tendrá diferentes reglas de acceso. Si no está configurado de forma forma segura, cualquiera podrá acceder a él.",
+12 -3
View File
@@ -145,7 +145,11 @@
"wrongCredentials": "Identifiants incorrects !",
"failedLogin": "Échec de la connexion",
"invalidCredentials": "Les informations d'identification fournies ne sont pas valides",
"passkeyLogin": "Utiliser une clé d'accès"
"passkeyLogin": "Utiliser une clé d'accès",
"requirePasswordChangeTitle": "Modifier votre mot de passe",
"requirePasswordChangeInstructions": "Vous devez définir un nouveau mot de passe avant de continuer.",
"requirePasswordChangeSuccess": "Mot de passe mis à jour. Vous êtes désormais connecté(e).",
"requirePasswordChangeMustDiffer": "Le nouveau mot de passe doit être différent de votre mot de passe actuel"
},
"prompts": {
"deleteMessageSingle": "Êtes-vous sûr de vouloir supprimer cet élément ?",
@@ -304,7 +308,10 @@
"accountDefaults": "Paramètres par défaut du compte",
"disableUserSettings": "Désactiver la page des paramètres utilisateur",
"userDefaultFieldLockedFromConfig": "Cette valeur par défaut est définie dans le fichier de configuration et ne peut pas être modifiée ici.",
"sourceAccessDefaultsHelp": "Droits d'accès par défaut appliqués aux fichiers sources et aux nouveaux utilisateurs. L'option « Enforce » impose une valeur fixe à tous les utilisateurs non administrateurs."
"sourceAccessDefaultsHelp": "Droits d'accès par défaut appliqués aux fichiers sources et aux nouveaux utilisateurs. L'option « Enforce » impose une valeur fixe à tous les utilisateurs non administrateurs.",
"passwordRequirementsMatch": "Les mots de passe correspondent",
"passwordRequirementsMinLength": "Au moins {min} caractères",
"requirePasswordChange": "Exiger le changement de mot de passe lors de la prochaine connexion"
},
"sidebar": {
"updateIsAvailable": "Une mise à jour est disponible !",
@@ -345,7 +352,9 @@
"usageTextDisk": "utilisation telle que rapportée par disque/partition",
"dividerNamePlaceholder": "Texte du séparateur (laisser en blanc pour montrer une ligne)",
"showIndexedUsage": "Afficher l'utilisation telle qu'elle est répertoriée",
"showDiskUsage": "Afficher l'utilisation telle qu'elle est indiquée par le disque/la partition"
"showDiskUsage": "Afficher l'utilisation telle qu'elle est indiquée par le disque/la partition",
"rootFilesystemOnly": "Limiter l'utilisation du disque au système de fichiers source",
"rootFilesystemOnlyDescription": "Lorsque cette option est activée, seul le système de fichiers contenant le chemin source est pris en compte. Lorsqu'elle est désactivée (par défaut), le total du disque inclut également les systèmes de fichiers montés sous le chemin source, les pools partagés tels que les ensembles de données ZFS n'étant comptés qu'une seule fois."
},
"share": {
"notice": "Remarque : lorsqu'un partage est créé, il est davantage exposé au public et les règles d'accès sont différentes. Si votre partage n'est pas configuré de manière sécurisée, n'importe qui peut y accéder.",
+12 -3
View File
@@ -145,7 +145,11 @@
"wrongCredentials": "פרטי התחברות שגויים",
"failedLogin": "נכשל בכניסה",
"invalidCredentials": "פרטי הזדהות לא חוקיים",
"passkeyLogin": "השתמש במפתח גישה"
"passkeyLogin": "השתמש במפתח גישה",
"requirePasswordChangeTitle": "שנה את הסיסמה שלך",
"requirePasswordChangeInstructions": "עליך להגדיר סיסמה חדשה לפני שתמשיך.",
"requirePasswordChangeSuccess": "הסיסמה עודכנה. כעת אתה מחובר.",
"requirePasswordChangeMustDiffer": "הסיסמה החדשה חייבת להיות שונה מהסיסמה הנוכחית שלך"
},
"prompts": {
"deleteMessageSingle": "האם אתה בטוח שברצונך למחוק את הקובץ או התקייה?",
@@ -304,7 +308,10 @@
"accountDefaults": "הגדרות ברירת מחדל של החשבון",
"disableUserSettings": "השבת את דף הגדרות המשתמש",
"userDefaultFieldLockedFromConfig": "הגדרה ברירת המחדל הזו נקבעת בקובץ התצורה ואינה ניתנת לשינוי כאן.",
"sourceAccessDefaultsHelp": "הרשאות הקובץ המוגדרות כברירת מחדל מוחלות על מקורות ועל משתמשים חדשים. האפשרות \"אכוף\" נועלת ערך זה עבור כל המשתמשים שאינם מנהלים."
"sourceAccessDefaultsHelp": "הרשאות הקובץ המוגדרות כברירת מחדל מוחלות על מקורות ועל משתמשים חדשים. האפשרות \"אכוף\" נועלת ערך זה עבור כל המשתמשים שאינם מנהלים.",
"passwordRequirementsMatch": "הסיסמאות תואמות",
"passwordRequirementsMinLength": "לפחות {min} תווים",
"requirePasswordChange": "דרוש שינוי סיסמה בכניסה הבאה"
},
"sidebar": {
"updateIsAvailable": "עדכון זמין!",
@@ -345,7 +352,9 @@
"usageTextDisk": "שימוש כפי שדווח על ידי דיסק/מחיצה",
"dividerNamePlaceholder": "טקסט מפריד (השאר ריק כדי להציג קו)",
"showIndexedUsage": "הצג את השימוש כפי שמופיע באינדקס",
"showDiskUsage": "הצג את השימוש כפי שדווח על ידי הדיסק/המחיצה"
"showDiskUsage": "הצג את השימוש כפי שדווח על ידי הדיסק/המחיצה",
"rootFilesystemOnly": "הגבל את השימוש בדיסק למערכת הקבצים המקורית",
"rootFilesystemOnlyDescription": "כאשר האפשרות מופעלת, מדווחת רק מערכת הקבצים המכילה את נתיב המקור. כאשר האפשרות מושבתת (ברירת מחדל), סך נפח הדיסק כולל גם את מערכות הקבצים שעברו התקנה תחת נתיב המקור, כאשר מאגרים משותפים כגון מערכי נתונים של ZFS נספרים פעם אחת בלבד."
},
"share": {
"notice": "הערה: כאשר נוצר שיתוף, הוא הופך לחשוף יותר לציבור וחל עליו כללים שונים לגבי גישה. אם השיתוף שלך אינו מוגדר בצורה מאובטחת, כל אחד יכול לגשת אליו.",
+12 -3
View File
@@ -145,7 +145,11 @@
"wrongCredentials": "Hibás hitelesítő adatok",
"failedLogin": "Sikertelen bejelentkezés",
"invalidCredentials": "Érvénytelen hitelesítő adatok megadása",
"passkeyLogin": "Jelszókulcs használata"
"passkeyLogin": "Jelszókulcs használata",
"requirePasswordChangeTitle": "Jelszó módosítása",
"requirePasswordChangeInstructions": "A folytatáshoz új jelszót kell beállítania.",
"requirePasswordChangeSuccess": "A jelszó frissítve. Most már be van jelentkezve.",
"requirePasswordChangeMustDiffer": "Az új jelszónak különböznie kell a jelenlegi jelszótól"
},
"prompts": {
"deleteMessageSingle": "Biztosan törölni szeretné ezt a fájl vagy mappát?",
@@ -304,7 +308,10 @@
"accountDefaults": "Fiók alapbeállításai",
"disableUserSettings": "A felhasználói beállítások oldal letiltása",
"userDefaultFieldLockedFromConfig": "Ez az alapértelmezett beállítás a konfigurációs fájlban van megadva, és itt nem módosítható.",
"sourceAccessDefaultsHelp": "A forrásfájlokra és az új felhasználókra alkalmazott alapértelmezett fájlhozzáférési jogosultságok. Az „Enforce” beállítás egy értéket rögzít minden nem rendszergazda felhasználó számára."
"sourceAccessDefaultsHelp": "A forrásfájlokra és az új felhasználókra alkalmazott alapértelmezett fájlhozzáférési jogosultságok. Az „Enforce” beállítás egy értéket rögzít minden nem rendszergazda felhasználó számára.",
"passwordRequirementsMatch": "A jelszavak egyeznek",
"passwordRequirementsMinLength": "Legalább {min} karakter",
"requirePasswordChange": "A következő bejelentkezéskor jelszóváltás szükséges"
},
"sidebar": {
"updateIsAvailable": "Elérhető a frissítés!",
@@ -345,7 +352,9 @@
"usageTextDisk": "lemez/partíció által bejelentett használat",
"dividerNamePlaceholder": "Választószöveg (üresen hagyva egy sor megjelenítéséhez)",
"showIndexedUsage": "Az indexelt használat megjelenítése",
"showDiskUsage": "A lemez/partíció által jelentett felhasználás megjelenítése"
"showDiskUsage": "A lemez/partíció által jelentett felhasználás megjelenítése",
"rootFilesystemOnly": "A lemezhasználat korlátozása a forrásfájlrendszerre",
"rootFilesystemOnlyDescription": "Ha engedélyezve van, akkor csak a forrásútvonalat tartalmazó fájlrendszer kerül jelentésre. Ha letiltva van (alapértelmezett), akkor a lemez teljes mérete magában foglalja a forrásútvonal alá csatlakoztatott fájlrendszereket is, wobei a megosztott tárolók, például a ZFS-adatállományok, csak egyszer kerülnek beszámításra."
},
"share": {
"notice": "Megjegyzés: amikor egy megosztás létrejön, akkor az jobban ki van téve a nyilvánosságnak, és más hozzáférési szabályok vonatkoznak rá. Ha a megosztás nincs biztonságosan konfigurálva, bárki hozzáférhet hozzá.",
+12 -3
View File
@@ -309,7 +309,11 @@
"wrongCredentials": "Credenziali errate",
"failedLogin": "Accesso fallito",
"invalidCredentials": "Credenziali fornite non valide",
"passkeyLogin": "Usa Passkey"
"passkeyLogin": "Usa Passkey",
"requirePasswordChangeTitle": "Cambia la tua password",
"requirePasswordChangeInstructions": "È necessario impostare una nuova password prima di continuare.",
"requirePasswordChangeSuccess": "La password è stata aggiornata. Ora hai effettuato l'accesso.",
"requirePasswordChangeMustDiffer": "La nuova password deve essere diversa da quella attuale"
},
"share": {
"notice": "Nota: quando viene creata una condivisione, essa diventa pubblica e segue regole di accesso diverse. Se non configurata correttamente, chiunque potrebbe accedervi.",
@@ -618,7 +622,10 @@
"accountDefaults": "Impostazioni predefinite dell'account",
"disableUserSettings": "Disattiva la pagina delle impostazioni utente",
"userDefaultFieldLockedFromConfig": "Questa impostazione predefinita è configurata nel file di configurazione e non può essere modificata in questa sede.",
"sourceAccessDefaultsHelp": "Permessi predefiniti applicati ai file sorgente e ai nuovi utenti. L'opzione \"Enforce\" blocca un valore per tutti gli utenti non amministratori."
"sourceAccessDefaultsHelp": "Permessi predefiniti applicati ai file sorgente e ai nuovi utenti. L'opzione \"Enforce\" blocca un valore per tutti gli utenti non amministratori.",
"passwordRequirementsMatch": "Le password corrispondono",
"passwordRequirementsMinLength": "Almeno {min} caratteri",
"requirePasswordChange": "Richiedi la modifica della password al prossimo accesso"
},
"sidebar": {
"dividerNamePlaceholder": "Testo divisore (vuoto per una linea)",
@@ -659,7 +666,9 @@
"noLinksYet": "Nessun link configurato. Aggiungi il primo qui sotto.",
"switchMode": "Cambia modalità barra laterale",
"showIndexedUsage": "Mostra l'utilizzo come indicizzato",
"showDiskUsage": "Mostra l'utilizzo in base ai dati riportati dal disco/dalla partizione"
"showDiskUsage": "Mostra l'utilizzo in base ai dati riportati dal disco/dalla partizione",
"rootFilesystemOnly": "Limita l'utilizzo del disco al filesystem di origine",
"rootFilesystemOnlyDescription": "Se abilitata, viene segnalato solo il filesystem contenente il percorso di origine. Se disabilitata (impostazione predefinita), il totale del disco include anche i filesystem montati sotto il percorso di origine, con i pool condivisi come i dataset ZFS conteggiati una sola volta."
},
"api": {
"title": "Token API",
+12 -3
View File
@@ -145,7 +145,11 @@
"wrongCredentials": "ユーザー名またはパスワードが間違っています。",
"failedLogin": "ログインに失敗しました",
"invalidCredentials": "無効な認証情報が提供された",
"passkeyLogin": "パスキーを使用"
"passkeyLogin": "パスキーを使用",
"requirePasswordChangeTitle": "パスワードを変更する",
"requirePasswordChangeInstructions": "続行するには、新しいパスワードを設定する必要があります。",
"requirePasswordChangeSuccess": "パスワードが更新されました。これでログインが完了しました。",
"requirePasswordChangeMustDiffer": "新しいパスワードは、現在のパスワードとは異なるものにしてください"
},
"prompts": {
"deleteMessageSingle": "このファイル/フォルダを本当に削除してよろしいですか。",
@@ -304,7 +308,10 @@
"accountDefaults": "アカウントのデフォルト設定",
"disableUserSettings": "ユーザー設定ページを無効にする",
"userDefaultFieldLockedFromConfig": "このデフォルト設定は設定ファイルで指定されており、ここでは変更できません。",
"sourceAccessDefaultsHelp": "ソースおよび新規ユーザーにデフォルトのファイル権限が適用されます。「強制」を選択すると、管理者以外のすべてのユーザーに対して値が固定されます。"
"sourceAccessDefaultsHelp": "ソースおよび新規ユーザーにデフォルトのファイル権限が適用されます。「強制」を選択すると、管理者以外のすべてのユーザーに対して値が固定されます。",
"passwordRequirementsMatch": "パスワードが一致しています",
"passwordRequirementsMinLength": "{min} 文字以上",
"requirePasswordChange": "次回のログイン時にパスワードの変更を必須にする"
},
"sidebar": {
"updateIsAvailable": "アップデートが利用可能です!",
@@ -345,7 +352,9 @@
"usageTextDisk": "ディスク/パーティションごとの使用量",
"dividerNamePlaceholder": "分割テキスト(行を表示するには空白にする)",
"showIndexedUsage": "インデックスに登録された使用例を表示する",
"showDiskUsage": "ディスク/パーティションごとの使用状況を表示する"
"showDiskUsage": "ディスク/パーティションごとの使用状況を表示する",
"rootFilesystemOnly": "ディスク使用量をソースファイルシステムに制限する",
"rootFilesystemOnlyDescription": "有効にすると、ソースパスを含むファイルシステムのみが報告されます。無効(デフォルト)の場合は、ディスクの合計容量にソースパスの下にマウントされたファイルシステムも含まれ、ZFSデータセットなどの共有プールは1回のみカウントされます。"
},
"share": {
"notice": "注意:共有が作成されると、公開されるようになり、アクセスに関するルールが異なります。共有が安全に設定されていない場合、誰でもアクセスできる可能性があります。",
+12 -3
View File
@@ -145,7 +145,11 @@
"wrongCredentials": "사용자 이름 또는 비밀번호를 확인하십시오",
"failedLogin": "로그인에 실패했습니다.",
"invalidCredentials": "제공된 자격 증명이 잘못되었습니다.",
"passkeyLogin": "패스키 사용"
"passkeyLogin": "패스키 사용",
"requirePasswordChangeTitle": "비밀번호 변경",
"requirePasswordChangeInstructions": "계속 진행하려면 새 비밀번호를 설정해야 합니다.",
"requirePasswordChangeSuccess": "비밀번호가 업데이트되었습니다. 이제 로그인이 완료되었습니다.",
"requirePasswordChangeMustDiffer": "새 비밀번호는 현재 비밀번호와 달라야 합니다."
},
"prompts": {
"deleteMessageSingle": "파일 혹은 디렉토리를 삭제하시겠습니까?",
@@ -304,7 +308,10 @@
"accountDefaults": "계정 기본 설정",
"disableUserSettings": "사용자 설정 페이지 비활성화",
"userDefaultFieldLockedFromConfig": "이 기본값은 설정 파일에서 지정되며, 여기서는 변경할 수 없습니다.",
"sourceAccessDefaultsHelp": "소스 및 신규 사용자에게 기본 파일 권한이 적용됩니다. ‘enforce’는 관리자 권한이 없는 모든 사용자에 대해 해당 값을 강제 적용합니다."
"sourceAccessDefaultsHelp": "소스 및 신규 사용자에게 기본 파일 권한이 적용됩니다. ‘enforce’는 관리자 권한이 없는 모든 사용자에 대해 해당 값을 강제 적용합니다.",
"passwordRequirementsMatch": "비밀번호가 일치합니다",
"passwordRequirementsMinLength": "{min} 자 이상",
"requirePasswordChange": "다음 로그인 시 비밀번호 변경을 필수로 설정"
},
"sidebar": {
"updateIsAvailable": "업데이트를 사용할 수 있습니다!",
@@ -345,7 +352,9 @@
"usageTextDisk": "디스크/파티션별로 보고된 사용량",
"dividerNamePlaceholder": "디바이더 텍스트(줄을 표시하려면 비워둡니다)",
"showIndexedUsage": "색인에 등록된 대로 사용법 표시",
"showDiskUsage": "디스크/파티션별로 보고된 사용량 표시"
"showDiskUsage": "디스크/파티션별로 보고된 사용량 표시",
"rootFilesystemOnly": "디스크 사용량을 소스 파일 시스템으로 제한",
"rootFilesystemOnlyDescription": "이 옵션이 활성화되면 소스 경로가 포함된 파일 시스템만 보고됩니다. 비활성화(기본값)된 경우, 디스크 총 용량에는 소스 경로 아래에 마운트된 파일 시스템도 포함되며, ZFS 데이터셋과 같은 공유 풀은 한 번만 집계됩니다."
},
"share": {
"notice": "참고: 공유가 생성되면 공개에 더 많이 노출되고 액세스 규칙이 달라집니다. 공유를 안전하게 구성하지 않으면 누구나 액세스할 수 있습니다.",
+12 -3
View File
@@ -145,7 +145,11 @@
"wrongCredentials": "Verkeerde inloggegevens",
"failedLogin": "Aanmelden mislukt",
"invalidCredentials": "Ongeldige referenties verstrekt",
"passkeyLogin": "Passkey gebruiken"
"passkeyLogin": "Passkey gebruiken",
"requirePasswordChangeTitle": "Wijzig je wachtwoord",
"requirePasswordChangeInstructions": "U moet een nieuw wachtwoord instellen voordat u verder kunt gaan.",
"requirePasswordChangeSuccess": "Het wachtwoord is bijgewerkt. Je bent nu ingelogd.",
"requirePasswordChangeMustDiffer": "Het nieuwe wachtwoord moet anders zijn dan je huidige wachtwoord"
},
"prompts": {
"deleteMessageSingle": "Weet u zeker dat u dit bestand/map wil verwijderen?",
@@ -304,7 +308,10 @@
"accountDefaults": "Standaardinstellingen voor accounts",
"disableUserSettings": "Pagina met gebruikersinstellingen uitschakelen",
"userDefaultFieldLockedFromConfig": "Deze standaardinstelling is vastgelegd in het configuratiebestand en kan hier niet worden gewijzigd.",
"sourceAccessDefaultsHelp": "Standaard bestandsrechten die worden toegepast op bronbestanden en nieuwe gebruikers. Met ‘Enforce’ wordt een waarde vastgelegd voor alle gebruikers die geen beheerder zijn."
"sourceAccessDefaultsHelp": "Standaard bestandsrechten die worden toegepast op bronbestanden en nieuwe gebruikers. Met ‘Enforce’ wordt een waarde vastgelegd voor alle gebruikers die geen beheerder zijn.",
"passwordRequirementsMatch": "Wachtwoorden komen overeen",
"passwordRequirementsMinLength": "{min} -tekens",
"requirePasswordChange": "Vereis dat het wachtwoord bij de volgende aanmelding wordt gewijzigd"
},
"sidebar": {
"updateIsAvailable": "Er is een update beschikbaar!",
@@ -345,7 +352,9 @@
"usageTextDisk": "gebruik zoals gerapporteerd door schijf/partitie",
"dividerNamePlaceholder": "Scheidingstekst (leeg laten om een lijn weer te geven)",
"showIndexedUsage": "Gebruik weergeven zoals geïndexeerd",
"showDiskUsage": "Weergave van het gebruik zoals gerapporteerd per schijf/partitie"
"showDiskUsage": "Weergave van het gebruik zoals gerapporteerd per schijf/partitie",
"rootFilesystemOnly": "Het schijfgebruik beperken tot het bronbestandssysteem",
"rootFilesystemOnlyDescription": "Indien deze optie is ingeschakeld, wordt alleen het bestandssysteem met het bronpad weergegeven. Indien deze optie is uitgeschakeld (standaard), omvat het totale schijfvolume ook de bestandssystemen die onder het bronpad zijn gekoppeld, waarbij gedeelde pools zoals ZFS-datasets slechts één keer worden meegeteld."
},
"share": {
"notice": "Opmerking: wanneer een share wordt aangemaakt, wordt deze meer blootgesteld aan het publiek en gelden er andere toegangsregels. Als je share niet veilig is geconfigureerd, kan iedereen er toegang tot krijgen.",
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more