Compare commits

..
89 changed files with 1089 additions and 3714 deletions

No files matched your search

-33
View File
@@ -2,38 +2,6 @@
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.7
**New Features**:
- Add cli init CLI command (#2957)
**Notes**:
- Pop-up preview has 200ms debounce delay so it doesn't flash from moving the cursor across files quickly.
- Added risc-v to official releases
**Bugfixes**:
- Hide the Replace option on upload/create conflict prompts when the user lacks modify permission (or when a public share disallows replacements), so create-only users are not offered an action that the server rejects (#2837)
- Preserve Ctrl-click file selection when keyboard state is stale (#2958) (#2923)
- Avoid false stalls during parallel transfers (#2950) (#2948) thanks @gudcks0305
- Cap source usage-bar percentage at 100% when indexed size exceeds partition total (#2761) (#2238)
- On Linux, source partition totals sum distinct filesystems mounted under the source root (nested mounts) so usage bars match indexed content (#2761)
## v2.0.6
**Security**:
- Share download links no longer create links with token, instead they link to the UI prompting for password before download. If a direct download is required, the `/api/share/direct` api exists and documented by swagger. (#2888)
**Notes**:
- renaming sources updates sidebar links (#2878)
- disabling/deleting a user source removes that source from sidebar links (#2942)
**Bugfixes**:
- Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2894)
- Support non-ASCII share passwords (#2933)
- Fall back to buffered copies when FUSE rejects fast paths (#2938) (#2924)
- Preserve deleted sidebar links across restarts (#2935)
## v2.0.5
**New Features**:
@@ -50,7 +18,6 @@ All notable changes to this project will be documented in this file. For commit
- tilde paths for sources were not properly expanded. (eg "~" for home)
- fixed tooltip not showing up for some circumstances on mobile, improved consistency.
- a few styling fixes for inconsistencies (#2908)
- Disk-usage widget no longer reports PB/TB-scale garbage on Docker Desktop for Mac (virtiofs) bind mounts; space is computed from the filesystem fragment size (`f_frsize`) instead of the optimal-transfer block size (`f_bsize`) on Linux ([#2894](https://github.com/gtsteffaniak/filebrowser/issues/2894)).
## v2.0.4
-16
View File
@@ -49,22 +49,6 @@ builds:
post:
- upx {{ .Path }}
# Build configuration for linux riscv64
- id: linux-riscv64
ldflags: *ldflags
main: main.go
binary: filebrowser
env:
- CC=zig cc -target riscv64-linux-musl
# go-fitz does not provide riscv64 MuPDF libraries yet: https://github.com/gen2brain/go-fitz/tree/master/libs
tags:
- analytics
- musl
goos:
- linux
goarch:
- riscv64
# Build configuration for linux
- id: linux-arm32
ldflags: *ldflags
-8
View File
@@ -40,14 +40,6 @@ func TestParseVersionCommand(t *testing.T) {
assert.Equal(t, "version", ctx.Command())
}
func TestParseInitCommand(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"init"})
require.NoError(t, err)
assert.Equal(t, "init", ctx.Command())
}
func TestParseGlobalConfigFlag(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
+1 -8
View File
@@ -25,8 +25,6 @@ type versionCmd struct{}
type setupCmd struct{}
type initCmd struct{}
type SetCmd struct {
User string `short:"u" help:"Deprecated: comma-separated username,password. Use 'user set' instead."`
Admin bool `short:"a" help:"Create user as admin (used with -u)"`
@@ -118,7 +116,6 @@ type cliRoot struct {
Run runCmd `cmd:"" default:"1" hidden:"" help:"Start the FileBrowser server"`
Version versionCmd `cmd:"" name:"version" help:"Print version information"`
Setup setupCmd `cmd:"" name:"setup" help:"Interactive configuration setup"`
Init initCmd `cmd:"" name:"init" hidden:"" help:"Initialize the database or migrate from a legacy database"`
Set SetCmd `cmd:"" name:"set" help:"Set configuration values (deprecated: use 'user set' for users)"`
User UserCmd `cmd:"" name:"user" help:"User management"`
}
@@ -136,10 +133,6 @@ func (setupCmd) Run(globals *Globals) error {
return createConfig(globals.Config, globals.NoInput)
}
func (initCmd) Run() error {
return nil
}
func (s *SetCmd) Run(ctx *kong.Context) error {
if strings.HasPrefix(ctx.Command(), "set rule") {
return nil
@@ -222,7 +215,7 @@ func runCLI() (keepGoing bool, dbExists bool) {
case cmd == "version" || cmd == "setup":
parser.FatalIfErrorf(ctx.Run(&rootCLI))
return false, false
case cmd == "init" || cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
case cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
requireExistingConfig(configPath)
dbExists = initializeDatabase(configPath, true)
parser.FatalIfErrorf(ctx.Run(&rootCLI))
+20 -10
View File
@@ -7,10 +7,10 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -27,8 +27,7 @@ func validateUserInfo(newDB bool) {
changedFields := make([]string, 0, 8)
changePass := false
scopesChanged := updateUserScopes(user)
if scopesChanged {
if updateUserScopes(user) {
changedFields = append(changedFields, "backendScopes")
}
if updatePermissions(user) {
@@ -46,7 +45,7 @@ func validateUserInfo(newDB bool) {
if updateShowFirstLogin(user) {
changedFields = append(changedFields, "showFirstLogin")
}
if updateSidebarLinks(user, scopesChanged) {
if updateSidebarLinks(user) {
changedFields = append(changedFields, "sidebarLinks")
}
if updateTokens(user) {
@@ -233,17 +232,28 @@ func updatePreviewSettings(user *users.User) bool {
return false
}
// updateSidebarLinks normalizes saved links and adds defaults for new or changed scopes.
func updateSidebarLinks(user *users.User, scopesChanged bool) bool {
links, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks)
if scopesChanged || user.SidebarLinks == nil {
links, changed = usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
}
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
func updateSidebarLinks(user *users.User) bool {
needsEnsure := usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
validBefore := usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks)
links, changed := usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
if !changed {
return false
}
user.SidebarLinks = links
if needsEnsure {
if validBefore == 0 && len(user.SidebarLinks) > 0 {
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
} else if validBefore == 0 {
logger.Infof("User %s has no source sidebar links, building from scopes", user.Username)
} else {
logger.Infof("User %s is missing sidebar links for some scoped sources, merging from scopes", user.Username)
}
}
return true
}
+1 -1
View File
@@ -79,7 +79,7 @@ func TestMigrationGraham_gainsAccessAfterScopeMerge(t *testing.T) {
updateUserScopes(graham)
updateSourcePermissions(graham)
updateSidebarLinks(graham, true)
updateSidebarLinks(graham)
foundScope := false
for _, scope := range graham.BackendScopes {
+2 -36
View File
@@ -33,7 +33,7 @@ func TestUpdateSidebarLinks_dedupesDuplicateMigratedLinks(t *testing.T) {
},
}
if !updateSidebarLinks(user, false) {
if !updateSidebarLinks(user) {
t.Fatal("expected updateSidebarLinks to return true")
}
@@ -63,7 +63,7 @@ func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
},
}
if !updateSidebarLinks(user, false) {
if !updateSidebarLinks(user) {
t.Fatal("expected updateSidebarLinks to return true")
}
@@ -77,37 +77,3 @@ func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
}
}
func TestUpdateSidebarLinks_keepsDeletedSourceLinkAbsent(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
NonAdminEditable: users.NonAdminEditable{
SidebarLinks: []users.SidebarLink{
{Name: "playwright + files", Category: "source", SourceName: fixturePlaywrightSource, Target: "/"},
{Name: "docker", Category: "source", SourceName: fixtureDockerSource, Target: "/"},
},
},
},
BackendScopes: []users.BackendScope{
{Path: fixturePlaywrightSource, Scope: "/"},
{Path: fixtureDockerSource, Scope: "/"},
{Path: fixtureAccessSource, Scope: "/"},
},
}
if !updateSidebarLinks(user, false) {
t.Fatal("expected default source names to be blanked on normalize")
}
if len(user.SidebarLinks) != 2 {
t.Fatalf("got %d sidebar links, want 2: %v", len(user.SidebarLinks), user.SidebarLinks)
}
for _, link := range user.SidebarLinks {
if link.Name != "" {
t.Fatalf("expected blank default name, got %#v", link)
}
}
}
+40 -41
View File
@@ -13,7 +13,7 @@ require (
github.com/gen2brain/go-fitz v1.28.2
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-playground/validator/v10 v10.30.4
github.com/go-webauthn/webauthn v0.18.1
github.com/go-webauthn/webauthn v0.18.0
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
@@ -22,23 +22,23 @@ require (
github.com/gtsteffaniak/go-ffmpeg v0.5.2
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.52
github.com/mattn/go-sqlite3 v1.14.50
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.12.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/mod v0.41.0
golang.org/x/net v0.59.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/time v0.16.0
golang.org/x/tools v0.50.0
golang.org/x/crypto v0.56.0
golang.org/x/image v0.45.0
golang.org/x/mod v0.40.0
golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.49.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.59.0
modernc.org/sqlite v1.58.0
)
require (
@@ -49,7 +49,7 @@ require (
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
dev.gaijin.team/go/exhaustruct/v5 v5.2.0 // indirect
dev.gaijin.team/go/exhaustruct/v5 v5.0.3 // indirect
dev.gaijin.team/go/golib v0.8.1 // indirect
github.com/4meepo/tagalign v1.4.4 // indirect
github.com/Abirdcfly/dupword v0.1.8 // indirect
@@ -74,13 +74,12 @@ require (
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.4 // indirect
github.com/andybalholm/brotli v1.2.3 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bep/helpers v0.12.0 // indirect
github.com/bits-and-blooms/bitset v1.25.0 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
@@ -96,7 +95,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260903151058-ae99b731b8c5 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -107,9 +106,9 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.14.0 // indirect
github.com/dave/dst v0.28.0 // indirect
github.com/dave/dst v0.27.4 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2/v2 v2.8.0 // indirect
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -119,10 +118,10 @@ require (
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.8 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ghostiam/protogetter v1.0.1 // indirect
github.com/ghostiam/protogetter v1.0.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-critic/go-critic v0.15.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
@@ -130,13 +129,13 @@ require (
github.com/go-openapi/jsonpointer v1.0.1 // indirect
github.com/go-openapi/jsonreference v1.0.2 // indirect
github.com/go-openapi/spec v1.0.1 // indirect
github.com/go-openapi/swag/conv v0.29.2 // indirect
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
github.com/go-openapi/swag/loading v0.29.2 // indirect
github.com/go-openapi/swag/pools v0.29.2 // indirect
github.com/go-openapi/swag/stringutils v0.29.2 // indirect
github.com/go-openapi/swag/typeutils v0.29.2 // indirect
github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
github.com/go-openapi/swag/conv v0.29.1 // indirect
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect
github.com/go-openapi/swag/loading v0.29.1 // indirect
github.com/go-openapi/swag/pools v0.29.1 // indirect
github.com/go-openapi/swag/stringutils v0.29.1 // indirect
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -147,13 +146,13 @@ require (
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.3.1 // indirect
github.com/go-webauthn/x v0.3.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.4 // indirect
github.com/gofrs/flock v0.13.1 // indirect
github.com/gohugoio/hashstructure v1.1.0 // indirect
github.com/gohugoio/hugo v0.166.0 // indirect
github.com/gohugoio/hugo v0.165.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 // indirect
github.com/golang/protobuf v1.5.4 // indirect
@@ -193,10 +192,10 @@ require (
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.10.0 // indirect
github.com/ldez/gomoddirectives v0.9.0 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
github.com/ldez/structtags v0.6.1 // indirect
github.com/ldez/tagliatelle v0.8.0 // indirect
github.com/ldez/tagliatelle v0.7.2 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
@@ -209,7 +208,7 @@ require (
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mattn/go-runewidth v0.0.29 // indirect
github.com/mgechev/revive v1.16.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
@@ -217,7 +216,7 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.13.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
@@ -251,7 +250,7 @@ require (
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.9.0 // indirect
github.com/sourcegraph/go-diff v0.8.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
@@ -275,7 +274,7 @@ require (
github.com/uudashr/iface v1.5.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v1.2.0 // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
@@ -284,23 +283,23 @@ require (
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.12.0 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.10.1 // indirect
go.augendre.info/fatcontext v0.10.0 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20260824195058-e88cd73687aa // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.8.1 // indirect
modernc.org/libc v1.76.0 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
mvdan.cc/gofumpt v0.12.0 // indirect
mvdan.cc/gofumpt v0.11.0 // indirect
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+98 -98
View File
@@ -12,8 +12,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y=
dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI=
dev.gaijin.team/go/exhaustruct/v5 v5.2.0 h1:HZB49Fshn8qHJCi9VzYtrcnR1jbLteRHXfnQz8Adams=
dev.gaijin.team/go/exhaustruct/v5 v5.2.0/go.mod h1:5IshpqhgGdKIrF8lBmi3M6YKmI7T3hMzAL9nrflko88=
dev.gaijin.team/go/exhaustruct/v5 v5.0.3 h1:yOeA7DNjlT8y4yfmN6nWWYYggA13N523YAj9/TXbuTM=
dev.gaijin.team/go/exhaustruct/v5 v5.0.3/go.mod h1:KwtBsX8nHHH1YxhxkpiBq6bfsmw5WnazWpNvJPHgY9Y=
dev.gaijin.team/go/golib v0.8.1 h1:JYju4x9BSo+QD/AYeHULVDcvEhiFg8wOi6pT0IaZF5E=
dev.gaijin.team/go/golib v0.8.1/go.mod h1:c5fu7t1RSGMxSQgcUYO1sODbzsYnOCXJLmHeNG1Eb+0=
github.com/4meepo/tagalign v1.4.4 h1:052JDGj2pBORc6NkD30dDXpsUpoYszDeUwr9/5YZuVA=
@@ -80,8 +80,8 @@ github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQ
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
github.com/andybalholm/brotli v1.2.4 h1:649LN6qF+/7JJYNN3Wdu4Dt5PS6eC/yP/Go3Bb0/AHI=
github.com/andybalholm/brotli v1.2.4/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI=
@@ -112,16 +112,16 @@ github.com/bep/helpers v0.12.0 h1:tD6V2DQW0B+FUynF2etR/106S/TO9akm+vA/Hk24GxY=
github.com/bep/helpers v0.12.0/go.mod h1:PfE7MGdA8sSQ19nyDh4tYbs5rAlStlJaDI21f/fnNps=
github.com/bep/imagemeta v1.0.0 h1:8JZmahegn5p1M9CpFmga7LoSxErpu1uFfpSzMUNju7A=
github.com/bep/imagemeta v1.0.0/go.mod h1:+Hlp195TfZpzsqCxtDKTG6eWdyz2+F2V/oCYfr3CZKA=
github.com/bep/lazycache v0.9.0 h1:JLpAJGotKPaacb7dgv0+qBLneQjcMhJ3g9MvEhDyZUg=
github.com/bep/lazycache v0.9.0/go.mod h1:E3yeEfnmWUNNuIIR/EX6srqQwsvAFHSLQ8tR+jClATg=
github.com/bep/lazycache v0.8.1 h1:ko6ASLjkPxyV5DMWoNNZ8B2M0weyjqXX8IZkjBoBtvg=
github.com/bep/lazycache v0.8.1/go.mod h1:pbEiFsZoq7cLXvrTll0AHOPEurB1aGGxx4jKjOtlx9w=
github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
github.com/bep/overlayfs v0.11.0 h1:aymHDGC0CHpvn0XvTfgpK6skCp16oMi+tdUF32l6pPs=
github.com/bep/overlayfs v0.11.0/go.mod h1:L+ggdoKm+Y7Xb4a1osd+/LOPG4qsY62snqRqJH5Mspc=
github.com/bep/textandbinarywriter v0.1.0 h1:KXmXsRN2Uhwhm1G3e/snM8+5SPQBJrCEpIosdIBR3po=
github.com/bep/textandbinarywriter v0.1.0/go.mod h1:dAcHveajlWWU7PXhp6Dn4PIAYDg2H13Huif9xMS2w8w=
github.com/bep/tmc v0.7.0 h1:e0axmUwy+5EGQ6qk4wiOyDFuTqn3vpnoXIrxEG+yCX0=
github.com/bep/tmc v0.7.0/go.mod h1:jNqktL5Su+ScC51fw6urQRvP9Dwr1hVVGr9a3rwuGb4=
github.com/bep/tmc v0.6.0 h1:5zWy4L+3gS+Kk8czzLC4g7ETaC3wkX9ZtTRdAdL8V4s=
github.com/bep/tmc v0.6.0/go.mod h1:SNHxc3o2WSNMAYqJcAO0rxFY+pbhZzMwjIHe5xaAue0=
github.com/bits-and-blooms/bitset v1.25.0 h1:0Ro0qF4abCkM6SqWPVj29sFhAbMPAZpaDD7xhJ10beM=
github.com/bits-and-blooms/bitset v1.25.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w=
@@ -153,8 +153,8 @@ github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1Di
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 h1:/hSCZO1puetbGd2JikHKN0nAcb2mFevzxJElFoduhaw=
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16/go.mod h1:JFvmQPZh3uAu7PpSZU91bcR0BrIy24HhW+0EsW1uGxE=
github.com/charmbracelet/ultraviolet v0.0.0-20260903151058-ae99b731b8c5 h1:omidyaLW3GwtN5N35bPZWCxygynfhMRO2dTJ+OCcjLg=
github.com/charmbracelet/ultraviolet v0.0.0-20260903151058-ae99b731b8c5/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -182,8 +182,8 @@ github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+f
github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88=
github.com/daixiang0/gci v0.14.0 h1:h6AcLqmjIOBgojhtzY2CvBnA6RawPTkBHgtMvYD5YZ8=
github.com/daixiang0/gci v0.14.0/go.mod h1:w9E+SWQ4aPQ+xYPUdqitGDoXpT4mayqOfk7Szz2U6wQ=
github.com/dave/dst v0.28.0 h1:EIEuRC5XmVKuZF2OeyMahXX9ARVt6Xxr8vI0WtIY7K8=
github.com/dave/dst v0.28.0/go.mod h1:C4mfimBeSHfsgskhR/mUYSE05s2ih+PW6g9FP3c49tE=
github.com/dave/dst v0.27.4 h1:d+EVnOZmphH+lUEXq9rit4GjsFSKJ3AhfRWf7eobTps=
github.com/dave/dst v0.27.4/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc=
github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -194,8 +194,8 @@ github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4m
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.8.0 h1:CekDhPLGfm+GAJmPVFIG+5dqMIQPkyHJll7BbdneDfw=
github.com/dlclark/regexp2/v2 v2.8.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4=
github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
@@ -234,18 +234,18 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gen2brain/go-fitz v1.28.2 h1:845G85N5TUgnq5oDqyYrW0JvehAkeo35UkkK2dJtW1M=
github.com/gen2brain/go-fitz v1.28.2/go.mod h1:pY2hqAjp9Zy7qfPI2gwbJMHBFAdZpVXOLrRxD82l3Bs=
github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA=
github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY=
github.com/ghostiam/protogetter v1.0.1 h1:q4LLTx2KJHJpxrfO3BniUDWoFkoqWD/UlHhClBlUlLM=
github.com/ghostiam/protogetter v1.0.1/go.mod h1:50Fh5yZfs6SFGrd1FlmT04NcguyEOxyjNK5zGD6/EvM=
github.com/getkin/kin-openapi v0.146.0 h1:RA/1RdxrSJW4oc1+6IfnYB6AO9CaGy8GTKPh0k4Ordo=
github.com/getkin/kin-openapi v0.146.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
github.com/ghostiam/protogetter v1.0.0 h1:g1s6hbyrNJKYJ2X/RcLMmfDo9afmxQe1MRmBitfxSAs=
github.com/ghostiam/protogetter v1.0.0/go.mod h1:50Fh5yZfs6SFGrd1FlmT04NcguyEOxyjNK5zGD6/EvM=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-critic/go-critic v0.15.0 h1:MDUZIQKytIl+vPEc0Igw51m4uLTdi1ZGz2jatEqNs1g=
@@ -269,22 +269,22 @@ github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnn
github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk=
github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0=
github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q=
github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k=
github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM=
github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY=
github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI=
github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c=
github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc=
github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk=
github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI=
github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I=
github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE=
github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM=
github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ=
github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA=
github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM=
github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c=
github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg=
github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc=
github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw=
github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg=
github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc=
github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0=
github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc=
github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M=
github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE=
github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk=
github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs=
github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8=
@@ -322,10 +322,10 @@ github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUN
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.18.1 h1:KaQw6M+ODLvxHwddyeo6zFhhmicfLup/BClhigB6A+0=
github.com/go-webauthn/webauthn v0.18.1/go.mod h1:s4rZTQnKHWxIh6G3yEGqlxvtiLceA1jigll8FpqSgQ8=
github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
github.com/go-webauthn/webauthn v0.18.0 h1:PC8R3PNLEmjZf++WwcQlo1Z39S9rf8ma69rlwkypZhA=
github.com/go-webauthn/webauthn v0.18.0/go.mod h1:ymzZQhx3D/PrDjznemBdQJ23gHTaSDxUchM7sH1lUCg=
github.com/go-webauthn/x v0.3.0 h1:Q2X9vbrlP0Ed+QGEzixh1hthGZlDnzVT0XH/9IIQ0kE=
github.com/go-webauthn/x v0.3.0/go.mod h1:5OkdSQdOy7taRXWqvNHggtaPffmW94ybu3rZEER4I+I=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
@@ -346,10 +346,10 @@ github.com/gohugoio/go-radix v1.2.0 h1:D5GTk8jIoeXirBSc2P4E4NdHKDrenk9k9N0ctU5Yr
github.com/gohugoio/go-radix v1.2.0/go.mod h1:k6vDa0ebpbpgtzSj9lPGJcA4AZwJ9xUNObUy2vczPFM=
github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4=
github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8=
github.com/gohugoio/httpcache v0.9.0 h1:f3Jb93RAP+qlmK7Ma5uGfwJslYDqTfzYJ6AeOT7nDe8=
github.com/gohugoio/httpcache v0.9.0/go.mod h1:YQsVn7oB30TG1QO9HoHc3Xq8dDQDVOdxBc0p7CYgbhE=
github.com/gohugoio/hugo v0.166.0 h1:MNGqY2FQQYofvHYMxdnvfnR/Sun+S6FDm7hd70qEG/4=
github.com/gohugoio/hugo v0.166.0/go.mod h1:jRSQi5aSXDeU2s2oJM1Z3eMRcaEuDZvWCrfjM71tFqE=
github.com/gohugoio/httpcache v0.8.0 h1:hNdsmGSELztetYCsPVgjA960zSa4dfEqqF/SficorCU=
github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
github.com/gohugoio/hugo v0.165.0 h1:E9uRFnDw3E+Nv69OU8vsD1YLI3+tRcSHhs82y4fBXdc=
github.com/gohugoio/hugo v0.165.0/go.mod h1:mOASrT3UGLvAXPdRuY6zUH9SoKBy7VoMQIzMFf4CMi8=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI=
@@ -497,14 +497,14 @@ github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ=
github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM=
github.com/ldez/gomoddirectives v0.10.0 h1:Zb23rfMMkG21B6pZ9OfESzemByP5Qr7YvWcXQZE/S3Q=
github.com/ldez/gomoddirectives v0.10.0/go.mod h1:W9uh0721Vc7aaVoiMXTpKIveLmtHC9uaOSiHM4OK5a8=
github.com/ldez/gomoddirectives v0.9.0 h1:2YV/EX7nVlWL4jySusYTzBKHuE3D2fgcRsQuMa3yIoo=
github.com/ldez/gomoddirectives v0.9.0/go.mod h1:DdZzfm9MdXCjn2/UGYXCFfo+tzrp2Ib4iD2Q0kIJkwE=
github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk=
github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY=
github.com/ldez/tagliatelle v0.8.0 h1:QczqVPa22JdT40p3m5JTTVmPMv5wDORqdomJn6/Pnn0=
github.com/ldez/tagliatelle v0.8.0/go.mod h1:K9n83Wv0wyVq3JYF9WI28BX8xXhDBVP3thHJ0ENuiEs=
github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk=
github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI=
github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc=
github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
@@ -535,10 +535,10 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ=
github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mgechev/revive v1.16.0 h1:9OZVO8xOPUwIYynplHXv7liMllPQ/BTNZLp07sM/Im0=
github.com/mgechev/revive v1.16.0/go.mod h1:nT854jMmvQsS/qqxEaMPM4pJSUpxhcPoHM788ZxDvdQ=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
@@ -561,8 +561,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
github.com/nishanths/exhaustive v0.13.0 h1:Rof0axOSNQaaPaPmLK5RxcRrJV2Vcv+vfUmuRy+2EAM=
github.com/nishanths/exhaustive v0.13.0/go.mod h1:3/adQjJgvD/UFHHSYBjpK9Wbz8DOvf0FBtzx+zI0Rlo=
github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
github.com/nunnatsa/ginkgolinter v0.24.0 h1:Mp0EagluLFP98JatP6nqp/gGEoljNG97uf9AcxcBVy8=
@@ -659,8 +659,8 @@ github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+W
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs=
github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
github.com/sourcegraph/go-diff v0.9.0 h1:7xcVsayWHhvxZGrqt1o89vvBApCa4nyCgO7tNxDHJ+Q=
github.com/sourcegraph/go-diff v0.9.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E=
github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQytHhvY=
github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -693,8 +693,8 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tdewolff/minify/v2 v2.24.17 h1:6AbitfVyq0M7aW6i+XL7+49DeTQZwloOMs9O574arBg=
github.com/tdewolff/minify/v2 v2.24.17/go.mod h1:kVqn9vxXUKtlHexSNrWbYePqioOT5mc4ou/KVSMpfCM=
github.com/tdewolff/minify/v2 v2.24.16 h1:1d+IHElvmYM2zJtuykMK/esiqc4H/7pqPKAa7jpSUUU=
github.com/tdewolff/minify/v2 v2.24.16/go.mod h1:G6yep6HFo3u2eoOcIVBKSNwpoW2wXvPjvnFRj3w1zlc=
github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8feI=
github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
@@ -733,8 +733,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -750,8 +750,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
@@ -764,8 +764,8 @@ go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4=
go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I=
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
go.augendre.info/fatcontext v0.10.1 h1:HCS/+eob32cM0mEtmZM6NiXdqvS5KiZmsR+fCubTpbc=
go.augendre.info/fatcontext v0.10.1/go.mod h1:pqpGvA9GlrXy+aXkp8L2dKz12Zp4g2FhzcAtwToU+2w=
go.augendre.info/fatcontext v0.10.0 h1:HhFopmivh8U1+AU7f0kuwUeg2eiIns7YsGQOMHwSJ90=
go.augendre.info/fatcontext v0.10.0/go.mod h1:pqpGvA9GlrXy+aXkp8L2dKz12Zp4g2FhzcAtwToU+2w=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
@@ -786,24 +786,24 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba h1:ZoZmFkcuQ289xJilBm9Ck3W+/okrhXc4LUpbtKFQE4k=
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba/go.mod h1:qkMnvgP8C7+xkci04txL2SJFc/0PGqnkf6gs5JUC43E=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/exp/typeparams v0.0.0-20260824195058-e88cd73687aa h1:0crASk1XE9p9RP0n9EbRutyWZoOkzTSuwTp3mqwppb0=
golang.org/x/exp/typeparams v0.0.0-20260824195058-e88cd73687aa/go.mod h1:qkMnvgP8C7+xkci04txL2SJFc/0PGqnkf6gs5JUC43E=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -821,10 +821,10 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -832,8 +832,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -851,23 +851,23 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
@@ -877,8 +877,8 @@ golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0t
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -904,10 +904,10 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w=
honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.2 h1:JPAIttQRHdY7aRdr04+iTW7Sx+6OSZcmKJ0OZl/tNaA=
modernc.org/ccgo/v4 v4.35.2/go.mod h1:9sddcpn4NuDAFGtBPa2Dk3NHfnQfcoKveCC5crwWp8I=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -916,8 +916,8 @@ modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.76.0 h1:eaJHMv2zn5oXT6IPXPwxAMVpzmQzSDsCdKcNl1ZpaRg=
modernc.org/libc v1.76.0/go.mod h1:2h0dedmVSE8qH2DrxzYDXbQaxLMl0XNg8Z7/HJRdk2M=
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
@@ -926,14 +926,14 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0=
modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/gofumpt v0.12.0 h1:1Lbudkz2kpM9Cjz2pL4M19u7q+GaEhCTNf7N9mfpcho=
mvdan.cc/gofumpt v0.12.0/go.mod h1:SmBHHrljiZu/uoypeKup3rFzP6eoC9UwCp2iH5E3jZA=
mvdan.cc/gofumpt v0.11.0 h1:0H01XB95PnN2QgCSR9ELdZyTlJqNZ7181B0BTMh5VZc=
mvdan.cc/gofumpt v0.11.0/go.mod h1:BeT5wCsOJt6J9zT2MZIOGszjUHzFkn1/l9g6xAzqsXo=
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 h1:Re1NRyLpiAt9kB+ImaaoapwWiQXrKwER4tY8fLOkDew=
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8/go.mod h1:MrS/+zJ1M2xvGXhKktiHbNQeQPyg3Qel+KkzT+f7/i4=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
+2 -15
View File
@@ -1,12 +1,10 @@
package fileutils
import (
"errors"
"io"
"os"
"path"
"path/filepath"
"syscall"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -128,7 +126,8 @@ func copySingleFile(source, dest string) error {
defer dst.Close()
// Copy the contents of the file.
if err = copyFileContents(dst, src); err != nil {
_, err = io.Copy(dst, src)
if err != nil {
return err
}
@@ -147,18 +146,6 @@ func copySingleFile(source, dest string) error {
return nil
}
func copyFileContents(dst io.Writer, src io.Reader) error {
_, err := io.Copy(dst, src)
if !errors.Is(err, syscall.EBADF) {
return err
}
// Some FUSE filesystems reject copy_file_range. Hide the optional fast-path
// methods so io.Copy retries from the current offsets with buffered I/O.
_, err = io.Copy(struct{ io.Writer }{dst}, struct{ io.Reader }{src})
return err
}
// copyDirectory handles copying directories recursively.
func copyDirectory(source, dest string) error {
srcInfo, err := os.Stat(source)
@@ -1,33 +1,12 @@
package fileutils
import (
"bytes"
"io"
"os"
"path/filepath"
"syscall"
"testing"
"time"
)
type copyFileRangeRejectingWriter struct {
bytes.Buffer
readFromCalls int
}
func (w *copyFileRangeRejectingWriter) ReadFrom(r io.Reader) (int64, error) {
w.readFromCalls++
buf := make([]byte, 4)
n, err := r.Read(buf)
if n > 0 {
_, _ = w.Write(buf[:n])
}
if err != nil {
return int64(n), err
}
return int64(n), syscall.EBADF
}
func TestUnixModeToFileMode_setgid(t *testing.T) {
m := unixModeToFileMode(0o2770)
if got := m.Perm(); got != 0o770 {
@@ -138,19 +117,3 @@ func TestCopyFilePreservesModTime(t *testing.T) {
})
}
}
func TestCopyFileContentsFallsBackAfterCopyFileRangeBadFileDescriptor(t *testing.T) {
const content = "complete file contents"
src := struct{ io.Reader }{bytes.NewBufferString(content)}
dst := &copyFileRangeRejectingWriter{}
if err := copyFileContents(dst, src); err != nil {
t.Fatal(err)
}
if got := dst.String(); got != content {
t.Fatalf("copied contents = %q, want %q", got, content)
}
if dst.readFromCalls != 1 {
t.Fatalf("ReadFrom calls = %d, want 1", dst.readFromCalls)
}
}
@@ -1,130 +0,0 @@
//go:build linux
package fileutils
import (
"fmt"
"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) {
if root == "" {
return 0, 0, fmt.Errorf("empty path")
}
root = filepath.Clean(root)
paths, parseErr := distinctMountPathsUnder(root)
if parseErr != nil || len(paths) == 0 {
return singlePathPartitionUsage(root)
}
var firstErr error
for _, path := range paths {
t, u, stErr := partitionUsageAt(path)
if stErr != nil {
if firstErr == nil {
firstErr = stErr
}
continue
}
total += t
used += u
}
if total == 0 && used == 0 {
if firstErr != nil {
return 0, 0, firstErr
}
return singlePathPartitionUsage(root)
}
return total, used, nil
}
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) {
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
}
func readMountinfo() (string, error) {
for _, path := range []string{"/proc/self/mountinfo", "/proc/1/mountinfo"} {
b, err := os.ReadFile(path)
if err == nil {
return string(b), nil
}
}
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) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, 0, err
}
bsize := blockSize(&stat)
total = uint64(stat.Blocks) * bsize
free := uint64(stat.Bavail) * bsize
if total >= free {
used = total - free
}
return total, used, nil
}
@@ -1,111 +0,0 @@
//go:build linux
package fileutils
import (
"bufio"
"path"
"strconv"
"strings"
)
type mountEntry struct {
deviceID string
mountpoint 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) {
root = path.Clean(root)
entries, err := parseAllMounts(mountinfo)
if err != nil {
return nil, err
}
seen := make(map[string]string)
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) {
covering = e
}
}
}
if covering != nil {
seen[covering.deviceID] = root
}
for _, e := range entries {
mp := e.mountpoint
if mp != root && !strings.HasPrefix(mp, root+"/") {
continue
}
if _, ok := seen[e.deviceID]; ok {
continue
}
seen[e.deviceID] = 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
}
func parseAllMounts(mountinfo string) ([]mountEntry, error) {
var out []mountEntry
scanner := bufio.NewScanner(strings.NewReader(mountinfo))
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
entry, ok := parseMountinfoLine(scanner.Text())
if !ok {
continue
}
entry.mountpoint = path.Clean(entry.mountpoint)
out = append(out, entry)
}
return out, scanner.Err()
}
func parseMountinfoLine(line string) (mountEntry, bool) {
parts := strings.SplitN(line, " - ", 2)
if len(parts) != 2 {
return mountEntry{}, false
}
fields := strings.Fields(parts[0])
if len(fields) < 5 {
return mountEntry{}, false
}
return mountEntry{
deviceID: fields[2],
mountpoint: unescapeFstab(fields[4]),
}, true
}
func unescapeFstab(path string) string {
var b strings.Builder
b.Grow(len(path))
for i := 0; i < len(path); i++ {
if path[i] == '\\' && i+3 < len(path) {
if n, err := strconv.ParseUint(path[i+1:i+4], 8, 8); err == nil {
b.WriteByte(byte(n))
i += 3
continue
}
}
b.WriteByte(path[i])
}
return b.String()
}
@@ -1,113 +0,0 @@
//go:build linux
package fileutils
import (
"sort"
"testing"
)
func TestDistinctMountPathsNestedDisk(t *testing.T) {
// /srv lives on rootfs 8:1; /srv/data is a separate 2TB disk 8:16.
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"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")
if err != nil {
t.Fatal(err)
}
sort.Strings(paths)
want := []string{"/srv", "/srv/data"}
if len(paths) != len(want) {
t.Fatalf("paths = %v, want %v", paths, want)
}
for i := range want {
if paths[i] != want[i] {
t.Fatalf("paths = %v, want %v", paths, want)
}
}
}
func TestDistinctMountPathsOvermountSamePathDeduped(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"40 22 8:2 / /srv rw - ext4 /dev/sda2 rw\n" +
"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")
if err != nil {
t.Fatal(err)
}
sort.Strings(paths)
want := []string{"/srv", "/srv/data"}
if len(paths) != len(want) {
t.Fatalf("paths = %v, want %v (no duplicate probe paths)", paths, want)
}
for i := range want {
if paths[i] != want[i] {
t.Fatalf("paths = %v, want %v", paths, want)
}
}
}
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"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
if len(paths) != 2 {
t.Fatalf("expected 2 distinct devices, got %v", paths)
}
}
func TestDistinctMountPathsSourceIsOwnMount(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"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")
if err != nil {
t.Fatal(err)
}
sort.Strings(paths)
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)
}
}
func TestDistinctMountPathsIgnoresUnrelated(t *testing.T) {
mountinfo := "" +
"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")
if err != nil {
t.Fatal(err)
}
if len(paths) != 1 || paths[0] != "/srv" {
t.Fatalf("paths = %v, want [/srv]", paths)
}
}
func TestParseMountinfoEscapedSpace(t *testing.T) {
entry, ok := parseMountinfoLine(`36 35 98:0 /mnt1 /mnt\040two rw - ext3 /dev/root rw`)
if !ok {
t.Fatal("expected parse ok")
}
if entry.mountpoint != "/mnt two" {
t.Fatalf("mountpoint = %q, want /mnt two", entry.mountpoint)
}
if entry.deviceID != "98:0" {
t.Fatalf("deviceID = %q, want 98:0", entry.deviceID)
}
}
@@ -1,17 +0,0 @@
//go:build !linux
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)
if err != nil {
return 0, 0, err
}
used, err = GetPartitionUsed(root)
if err != nil {
return 0, 0, err
}
return total, used, nil
}
@@ -1,47 +0,0 @@
//go:build linux
package fileutils
import (
"syscall"
)
func blockSize(stat *syscall.Statfs_t) uint64 {
if stat.Frsize > 0 {
return uint64(stat.Frsize)
}
return uint64(stat.Bsize)
}
// GetPartitionSize returns the filesystem size for Linux systems
func GetPartitionSize(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
return uint64(stat.Blocks) * blockSize(&stat), nil
}
// GetFreeSpace returns the available free space for Linux systems
func GetFreeSpace(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
return uint64(stat.Bavail) * blockSize(&stat), nil
}
// GetPartitionUsed returns the used space for Linux systems (total - free)
func GetPartitionUsed(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
bsize := blockSize(&stat)
total := uint64(stat.Blocks) * bsize
free := uint64(stat.Bavail) * bsize
return total - free, nil
}
@@ -1,48 +0,0 @@
//go:build linux
package fileutils
import (
"syscall"
"testing"
)
func TestBlockSizePrefersFrsize(t *testing.T) {
// virtiofs (Docker Desktop for Mac) reports Bsize as the 1 MiB optimal
// transfer size while Blocks are still counted in the 4 KiB fragment unit.
stat := &syscall.Statfs_t{
Bsize: 1048576,
Frsize: 4096,
Blocks: 1465121792,
Bavail: 1054717543,
}
got := blockSize(stat)
if got != 4096 {
t.Fatalf("blockSize = %d, want 4096 (Frsize)", got)
}
total := uint64(stat.Blocks) * blockSize(stat)
const wantTotal = 1465121792 * 4096 // ~5.5 TiB, matches df
if total != wantTotal {
t.Fatalf("total = %d bytes, want %d (~5.5 TiB)", total, uint64(wantTotal))
}
// Sanity: the old Bsize-based math overstates by exactly 256x.
if inflated := uint64(stat.Blocks) * uint64(stat.Bsize); inflated != total*256 {
t.Fatalf("expected 256x inflation from Bsize, got %d vs %d", inflated, total)
}
}
func TestBlockSizeFallsBackToBsize(t *testing.T) {
// Normal filesystems report Frsize == Bsize (or leave Frsize unset on
// exotic drivers); either way the accounting unit is recovered.
stat := &syscall.Statfs_t{Bsize: 4096, Frsize: 4096}
if got := blockSize(stat); got != 4096 {
t.Fatalf("blockSize = %d, want 4096", got)
}
zero := &syscall.Statfs_t{Bsize: 4096, Frsize: 0}
if got := blockSize(zero); got != 4096 {
t.Fatalf("blockSize with Frsize=0 = %d, want 4096 (Bsize fallback)", got)
}
}
@@ -1,4 +1,4 @@
//go:build !windows && !linux
//go:build !windows
package fileutils
-245
View File
@@ -1,245 +0,0 @@
package analytics
import (
"bufio"
"os"
"path/filepath"
"runtime"
"strings"
)
const (
hostPlatformUnknown = "unknown"
hostPlatformGenericLinux = "generic-linux"
hostPlatformUnraid = "unraid"
hostPlatformTrueNAS = "truenas"
hostPlatformSynology = "synology"
hostPlatformProxmox = "proxmox"
hostPlatformWindows = "windows"
hostPlatformMacOS = "macos"
)
// Roots where a Docker image may mount the host filesystem.
var hostRootPrefixes = []string{"/host", "/rootfs", "/mnt/host", ""}
// nativeHostRootPrefix is the scan root for non-container host detection. Tests may override.
var nativeHostRootPrefix = ""
func detectHostPlatform(containerRuntime string) string {
switch runtime.GOOS {
case "windows":
return hostPlatformWindows
case "darwin":
return hostPlatformMacOS
case "linux":
return detectLinuxHostPlatform(containerRuntime)
case "freebsd":
return detectFreeBSDHostPlatform(containerRuntime)
default:
return hostPlatformUnknown
}
}
func hostScanPrefixes(containerRuntime string) ([]string, bool) {
inContainer := containerRuntime == "docker" || containerRuntime == "kubernetes"
if !inContainer {
return []string{nativeHostRootPrefix}, false
}
return hostRootPrefixes, true
}
func detectLinuxHostPlatform(containerRuntime string) string {
prefixes, inContainer := hostScanPrefixes(containerRuntime)
for _, prefix := range prefixes {
if platform := detectLinuxMarkers(prefix); platform != "" {
return platform
}
}
for _, prefix := range prefixes {
if platform := classifyOSRelease(readOSRelease(prefix)); platform != "" {
if platform != hostPlatformGenericLinux || !inContainer || prefix != "" {
return platform
}
}
}
if inContainer {
return hostPlatformUnknown
}
if classifyOSRelease(readOSRelease(nativeHostRootPrefix)) == hostPlatformGenericLinux {
return hostPlatformGenericLinux
}
return hostPlatformUnknown
}
func detectFreeBSDHostPlatform(containerRuntime string) string {
prefixes, inContainer := hostScanPrefixes(containerRuntime)
for _, prefix := range prefixes {
if platform := classifyOSRelease(readOSRelease(prefix)); platform != "" {
if platform != hostPlatformGenericLinux {
return platform
}
}
if platform := classifyFreeBSDVersion(readFile(hostPath(prefix, "etc/version"))); platform != "" {
return platform
}
}
if inContainer {
return hostPlatformUnknown
}
return hostPlatformUnknown
}
func classifyFreeBSDVersion(content string) string {
lower := strings.ToLower(strings.TrimSpace(content))
switch {
case strings.Contains(lower, "truenas"), strings.Contains(lower, "freenas"):
return hostPlatformTrueNAS
default:
return ""
}
}
func detectLinuxMarkers(rootPrefix string) string {
markers := []struct {
path string
platform string
}{
{"etc/unraid-version", hostPlatformUnraid},
{"etc/synoinfo.conf", hostPlatformSynology},
{"etc/pve/.version", hostPlatformProxmox},
}
for _, marker := range markers {
if fileExists(hostPath(rootPrefix, marker.path)) {
return marker.platform
}
}
return ""
}
func readOSRelease(rootPrefix string) string {
return readFile(hostPath(rootPrefix, "etc/os-release"))
}
func hostPath(rootPrefix, rel string) string {
if rootPrefix == "" {
return filepath.Join(string(os.PathSeparator), rel)
}
return filepath.Join(rootPrefix, rel)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func readFile(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(data)
}
func parseOSRelease(content string) map[string]string {
values := make(map[string]string)
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"`)
}
return values
}
func classifyOSRelease(content string) string {
if strings.TrimSpace(content) == "" {
return ""
}
vars := parseOSRelease(content)
id := strings.ToLower(vars["ID"])
idLike := strings.ToLower(vars["ID_LIKE"])
pretty := strings.ToLower(vars["PRETTY_NAME"])
name := strings.ToLower(vars["NAME"])
combined := strings.Join([]string{id, idLike, pretty, name}, " ")
switch {
case strings.Contains(combined, "unraid"):
return hostPlatformUnraid
case strings.Contains(combined, "truenas"), strings.Contains(combined, "freenas"):
return hostPlatformTrueNAS
case strings.Contains(combined, "synology"):
return hostPlatformSynology
case strings.Contains(combined, "proxmox"):
return hostPlatformProxmox
case id != "":
return hostPlatformGenericLinux
default:
return ""
}
}
const (
deploymentRuntimeKubernetes = "kubernetes"
deploymentRuntimeDocker = "docker"
deploymentRuntimeLinux = "linux"
deploymentRuntimeWindows = "windows"
deploymentRuntimeMacOS = "macos"
deploymentRuntimeUnknown = "unknown"
)
// detectDeploymentRuntime reports how the instance is deployed: detected NAS/hypervisor
// product when possible, otherwise kubernetes, docker, or native OS (linux, windows, macos).
func detectDeploymentRuntime() string {
return deploymentRuntimeFrom(detectRuntime())
}
func deploymentRuntimeFrom(containerRuntime string) string {
platform := detectHostPlatform(containerRuntime)
if isProductDeploymentRuntime(platform) {
return platform
}
switch containerRuntime {
case "kubernetes":
return deploymentRuntimeKubernetes
case "docker":
return deploymentRuntimeDocker
}
switch runtime.GOOS {
case "windows":
return deploymentRuntimeWindows
case "darwin":
return deploymentRuntimeMacOS
case "linux":
return deploymentRuntimeLinux
default:
return deploymentRuntimeUnknown
}
}
func isProductDeploymentRuntime(platform string) bool {
switch platform {
case hostPlatformUnraid, hostPlatformTrueNAS, hostPlatformSynology, hostPlatformProxmox,
hostPlatformWindows, hostPlatformMacOS:
return true
default:
return false
}
}
@@ -1,207 +0,0 @@
package analytics
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestClassifyOSRelease(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{
name: "unraid",
content: `NAME="Unraid OS"
ID=unraid
ID_LIKE="debian"`,
want: hostPlatformUnraid,
},
{
name: "truenas scale",
content: `NAME="TrueNAS-SCALE"
ID=truenas-scale
ID_LIKE=debian`,
want: hostPlatformTrueNAS,
},
{
name: "freenas",
content: `PRETTY_NAME="FreeNAS-11.3-U5"`,
want: hostPlatformTrueNAS,
},
{
name: "synology",
content: `NAME="Synology"
ID=synology`,
want: hostPlatformSynology,
},
{
name: "proxmox",
content: `PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
ID=debian
NAME="Proxmox VE"`,
want: hostPlatformProxmox,
},
{
name: "generic ubuntu",
content: `NAME="Ubuntu"
ID=ubuntu
ID_LIKE=debian`,
want: hostPlatformGenericLinux,
},
{
name: "empty",
content: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyOSRelease(tt.content); got != tt.want {
t.Fatalf("classifyOSRelease() = %q, want %q", got, tt.want)
}
})
}
}
func withHostDetectionRoots(t *testing.T, roots []string, nativeRoot string) {
t.Cleanup(func() {
hostRootPrefixes = []string{"/host", "/rootfs", "/mnt/host", ""}
nativeHostRootPrefix = ""
})
hostRootPrefixes = roots
nativeHostRootPrefix = nativeRoot
}
func writeFixtureFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile(%q): %v", path, err)
}
}
func TestDetectLinuxHostPlatformMarkerPrecedence(t *testing.T) {
root := t.TempDir()
hostMount := filepath.Join(root, "host")
writeFixtureFile(t, filepath.Join(hostMount, "etc", "unraid-version"), "6.12.0")
writeFixtureFile(t, filepath.Join(hostMount, "etc", "os-release"), `NAME="Ubuntu"
ID=ubuntu`)
withHostDetectionRoots(t, []string{hostMount}, "")
if got := detectLinuxHostPlatform("docker"); got != hostPlatformUnraid {
t.Fatalf("detectLinuxHostPlatform() = %q, want %q", got, hostPlatformUnraid)
}
}
func TestDetectLinuxHostPlatformMountedOSRelease(t *testing.T) {
root := t.TempDir()
hostMount := filepath.Join(root, "host")
writeFixtureFile(t, filepath.Join(hostMount, "etc", "os-release"), `NAME="Synology"
ID=synology`)
withHostDetectionRoots(t, []string{hostMount}, "")
if got := detectLinuxHostPlatform("kubernetes"); got != hostPlatformSynology {
t.Fatalf("detectLinuxHostPlatform() = %q, want %q", got, hostPlatformSynology)
}
}
func TestDetectLinuxHostPlatformContainerUnknownFallback(t *testing.T) {
root := t.TempDir()
withHostDetectionRoots(t, []string{filepath.Join(root, "host")}, "")
if got := detectLinuxHostPlatform("docker"); got != hostPlatformUnknown {
t.Fatalf("detectLinuxHostPlatform() = %q, want %q", got, hostPlatformUnknown)
}
}
func TestDetectLinuxHostPlatformNativeSkipsHostMounts(t *testing.T) {
root := t.TempDir()
hostOnly := filepath.Join(root, "host")
nativeRoot := filepath.Join(root, "native")
writeFixtureFile(t, filepath.Join(hostOnly, "etc", "unraid-version"), "6.12.0")
writeFixtureFile(t, filepath.Join(nativeRoot, "etc", "os-release"), `NAME="Ubuntu"
ID=ubuntu`)
withHostDetectionRoots(t, []string{hostOnly}, nativeRoot)
if got := detectLinuxHostPlatform("native"); got != hostPlatformGenericLinux {
t.Fatalf("detectLinuxHostPlatform() = %q, want %q", got, hostPlatformGenericLinux)
}
}
func TestDetectFreeBSDHostPlatformTrueNASVersion(t *testing.T) {
root := t.TempDir()
hostMount := filepath.Join(root, "host")
writeFixtureFile(t, filepath.Join(hostMount, "etc", "version"), "TrueNAS-13.0-U6.1")
withHostDetectionRoots(t, []string{hostMount}, "")
if got := detectFreeBSDHostPlatform("docker"); got != hostPlatformTrueNAS {
t.Fatalf("detectFreeBSDHostPlatform() = %q, want %q", got, hostPlatformTrueNAS)
}
}
func TestClassifyFreeBSDVersion(t *testing.T) {
tests := map[string]string{
"TrueNAS-13.0-U6.1": hostPlatformTrueNAS,
"FreeNAS-11.3-U5": hostPlatformTrueNAS,
"13.2-RELEASE": "",
}
for input, want := range tests {
if got := classifyFreeBSDVersion(input); got != want {
t.Fatalf("classifyFreeBSDVersion(%q) = %q, want %q", input, got, want)
}
}
}
func TestDeploymentRuntimeFrom(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("deployment runtime integration requires GOOS linux")
}
t.Run("platform beats docker", func(t *testing.T) {
root := t.TempDir()
hostMount := filepath.Join(root, "host")
writeFixtureFile(t, filepath.Join(hostMount, "etc", "unraid-version"), "6.12.0")
withHostDetectionRoots(t, []string{hostMount}, "")
if got := deploymentRuntimeFrom("docker"); got != hostPlatformUnraid {
t.Fatalf("deploymentRuntimeFrom() = %q, want %q", got, hostPlatformUnraid)
}
})
t.Run("docker without host visibility", func(t *testing.T) {
emptyHost := filepath.Join(t.TempDir(), "host")
withHostDetectionRoots(t, []string{emptyHost}, "")
if got := deploymentRuntimeFrom("docker"); got != deploymentRuntimeDocker {
t.Fatalf("deploymentRuntimeFrom() = %q, want %q", got, deploymentRuntimeDocker)
}
})
t.Run("kubernetes", func(t *testing.T) {
withHostDetectionRoots(t, []string{filepath.Join(t.TempDir(), "host")}, "")
if got := deploymentRuntimeFrom("kubernetes"); got != deploymentRuntimeKubernetes {
t.Fatalf("deploymentRuntimeFrom() = %q, want %q", got, deploymentRuntimeKubernetes)
}
})
t.Run("native linux", func(t *testing.T) {
root := t.TempDir()
nativeRoot := filepath.Join(root, "native")
writeFixtureFile(t, filepath.Join(nativeRoot, "etc", "os-release"), `NAME="Ubuntu"
ID=ubuntu`)
withHostDetectionRoots(t, []string{filepath.Join(root, "unused-host")}, nativeRoot)
if got := deploymentRuntimeFrom("native"); got != deploymentRuntimeLinux {
t.Fatalf("deploymentRuntimeFrom() = %q, want %q", got, deploymentRuntimeLinux)
}
})
}
+3 -5
View File
@@ -52,9 +52,7 @@ type systemPayload struct {
CPUCores int `json:"cpu_cores"`
MemoryRSSMB int `json:"memory_rss_mb"`
MemoryTotalMB int `json:"memory_total_mb"`
// Runtime is deployment context: unraid, synology, truenas, proxmox, kubernetes,
// docker, linux, windows, macos, or unknown.
Runtime string `json:"runtime"`
Runtime string `json:"runtime"`
}
type countsPayload struct {
@@ -180,7 +178,7 @@ func collectPayload(versionLabel string) (snapshotPayload, error) {
CPUCores: runtime.NumCPU(),
MemoryRSSMB: memoryRSSMB(),
MemoryTotalMB: systemTotalMemoryMB(),
Runtime: detectDeploymentRuntime(),
Runtime: detectRuntime(),
},
Counts: countsPayload{
SourcesEnabled: sourceCounts.enabled,
@@ -310,7 +308,7 @@ func mapSystemOS(goos string) string {
func mapSystemArch(goarch string) string {
switch goarch {
case "amd64", "arm64", "386", "arm", "riscv64":
case "amd64", "arm64", "386", "arm":
return goarch
default:
return "other"
+11 -13
View File
@@ -3,6 +3,7 @@ package share
import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
@@ -61,34 +62,31 @@ func prepForFrontendOne(link *Share, viewer *users.User, r *http.Request, public
}
out.SingleFileShare = snap.IsSingleFileShare()
if r != nil && publicHost != "" {
out.DownloadURL = PublicShareURL(publicHost, publicScheme, out.Hash, true, out.HasPassword)
out.ShareURL = PublicShareURL(publicHost, publicScheme, out.Hash, false, out.HasPassword)
out.DownloadURL = PublicShareURL(publicHost, publicScheme, out.Hash, true, snap.Token)
out.ShareURL = PublicShareURL(publicHost, publicScheme, out.Hash, false, snap.Token)
}
return &out
}
// PublicShareURL builds share/download URLs using pre-resolved host and scheme (see web/proxy.go).
func PublicShareURL(host, scheme, hash string, isDirectDownload bool, hasPassword bool) string {
if isDirectDownload && hasPassword {
if settings.Config.Http.ExternalUrl != "" {
return fmt.Sprintf("%s%spublic/share/%s?download=true",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
}
return fmt.Sprintf("%s://%s%spublic/share/%s?download=true", scheme, host, settings.Config.Http.BaseURL, hash)
func PublicShareURL(host, scheme, hash string, isDirectDownload bool, token string) string {
tokenParam := ""
if token != "" && isDirectDownload {
tokenParam = fmt.Sprintf("&token=%s", url.QueryEscape(token))
}
if settings.Config.Http.ExternalUrl != "" {
if isDirectDownload {
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s%s",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash, tokenParam)
}
return fmt.Sprintf("%s%spublic/share/%s",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
}
if isDirectDownload {
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s",
scheme, host, settings.Config.Http.BaseURL, hash)
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s%s",
scheme, host, settings.Config.Http.BaseURL, hash, tokenParam)
}
return fmt.Sprintf("%s://%s%spublic/share/%s", scheme, host, settings.Config.Http.BaseURL, hash)
}
@@ -1,31 +0,0 @@
package share
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestPublicShareURLPasswordProtectedDirectLink(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.BaseURL = "/files/"
got := PublicShareURL("example.com", "https", "abc123", true, true)
want := "https://example.com/files/public/share/abc123?download=true"
if got != want {
t.Fatalf("PublicShareURL() = %q, want %q", got, want)
}
}
func TestPublicShareURLDirectDownloadWithoutPassword(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.BaseURL = "/files/"
got := PublicShareURL("example.com", "https", "abc123", true, false)
want := "https://example.com/files/public/api/resources/download?hash=abc123"
if got != want {
t.Fatalf("PublicShareURL() = %q, want %q", got, want)
}
}
+1
View File
@@ -112,6 +112,7 @@ type Share struct {
ShareColumns
PasswordHash string `json:"password_hash,omitempty"`
UserID uint64 `json:"userID,omitempty"`
Token string `json:"token,omitempty"`
UserDownloads map[string]int `json:"userDownloads,omitempty"`
Version int `json:"version,omitempty"`
SourcePath string `json:"sourcePath,omitempty"`
@@ -5,8 +5,9 @@ import (
"fmt"
)
// currentSchemaVersion is the SQLite schema marker for this codebase.
const currentSchemaVersion = 2
// currentSchemaVersion is the SQLite schema marker for this codebase (version 1).
// BoltDB has no schema_version; importing via cmd/migrate builds this SQLite shape directly.
const currentSchemaVersion = 1
// Schema creates all tables for the SQLite database
func createSchema(db *sql.DB) error {
@@ -38,6 +39,7 @@ func createSchema(db *sql.DB) error {
expire INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0,
password_hash TEXT,
token TEXT,
user_downloads TEXT,
share_settings TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 0
@@ -181,10 +183,6 @@ func runMigrations(db *sql.DB, fromVersion int) error {
switch v {
case 1:
// Canonical schema is createSchema + Bolt import; no step migrations.
case 2:
if err := normalizeLegacyShareTokens(db); err != nil {
return err
}
default:
return fmt.Errorf("unknown schema version: %d", v)
}
+35 -55
View File
@@ -40,7 +40,9 @@ func scanShareUserID(s string, dest *uint64) error {
// GetShareByHash retrieves a share by hash
func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE hash = ?`
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE hash = ?`
var link share.Share
var userIDStr string
@@ -54,6 +56,7 @@ func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
&link.Expire,
&link.Downloads,
&link.PasswordHash,
&link.Token,
&userDownloadsJSON,
&shareSettingsJSON,
&link.Version,
@@ -84,7 +87,9 @@ func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
// GetSharesByUserID retrieves all non-expired shares for an owner user id.
func (s *SQLStore) GetSharesByUserID(userID uint64) ([]*share.Share, error) {
now := time.Now().Unix()
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE user_id = ? AND (expire = 0 OR expire > ?)
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE user_id = ? AND (expire = 0 OR expire > ?)
ORDER BY path`
rows, err := s.db.Query(query, shareUserIDDB(userID), now)
@@ -98,7 +103,9 @@ func (s *SQLStore) GetSharesByUserID(userID uint64) ([]*share.Share, error) {
// GetSharesBySourcePath retrieves shares for a specific source and path
func (s *SQLStore) GetSharesBySourcePath(source, path string) ([]*share.Share, error) {
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? ORDER BY hash`
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE source = ? AND path = ? ORDER BY hash`
rows, err := s.db.Query(query, source, path)
if err != nil {
@@ -112,7 +119,9 @@ func (s *SQLStore) GetSharesBySourcePath(source, path string) ([]*share.Share, e
// GetSharesBySourcePathUser retrieves shares for a specific source, path, and owner user id.
func (s *SQLStore) GetSharesBySourcePathUser(source, path string, userID uint64) ([]*share.Share, error) {
now := time.Now().Unix()
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? AND user_id = ?
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE source = ? AND path = ? AND user_id = ?
AND (expire = 0 OR expire > ?) ORDER BY hash`
rows, err := s.db.Query(query, source, path, shareUserIDDB(userID), now)
@@ -126,7 +135,9 @@ func (s *SQLStore) GetSharesBySourcePathUser(source, path string, userID uint64)
// GetPermanentShare retrieves a permanent share (expire = 0) for source, path, and owner.
func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share.Share, error) {
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? AND user_id = ? AND expire = 0
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE source = ? AND path = ? AND user_id = ? AND expire = 0
LIMIT 1`
var link share.Share
@@ -141,6 +152,7 @@ func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share
&link.Expire,
&link.Downloads,
&link.PasswordHash,
&link.Token,
&userDownloadsJSON,
&shareSettingsJSON,
&link.Version,
@@ -171,7 +183,9 @@ func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share
// ListAllShares retrieves all non-expired shares
func (s *SQLStore) ListAllShares() ([]*share.Share, error) {
now := time.Now().Unix()
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE expire = 0 OR expire > ? ORDER BY path`
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE expire = 0 OR expire > ? ORDER BY path`
rows, err := s.db.Query(query, now)
if err != nil {
@@ -193,59 +207,24 @@ func (s *SQLStore) SaveShare(link *share.Share) error {
return fmt.Errorf("failed to marshal share settings: %w", err)
}
hasLegacyToken, err := s.sharesHasLegacyTokenColumn()
if err != nil {
return fmt.Errorf("failed to inspect shares schema: %w", err)
}
legacyToken := ""
if hasLegacyToken {
legacyToken, err = s.legacyShareToken(link.Hash)
if err != nil {
return err
}
}
var query string
var args []any
if hasLegacyToken {
query = `INSERT OR REPLACE INTO shares
query := `INSERT OR REPLACE INTO shares
(hash, user_id, source, path, expire, downloads, password_hash,
token, user_downloads, share_settings, version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
args = []any{
link.Hash,
shareUserIDDB(link.UserID),
link.SourcePath,
link.Path,
link.Expire,
link.Downloads,
link.PasswordHash,
legacyToken,
userDownloadsJSON,
shareSettingsJSON,
link.Version,
}
} else {
query = `INSERT OR REPLACE INTO shares
(hash, user_id, source, path, expire, downloads, password_hash,
user_downloads, share_settings, version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
args = []any{
link.Hash,
shareUserIDDB(link.UserID),
link.SourcePath,
link.Path,
link.Expire,
link.Downloads,
link.PasswordHash,
userDownloadsJSON,
shareSettingsJSON,
link.Version,
}
}
_, err = s.db.Exec(query, args...)
_, err = s.db.Exec(query,
link.Hash,
shareUserIDDB(link.UserID),
link.SourcePath,
link.Path,
link.Expire,
link.Downloads,
link.PasswordHash,
link.Token,
userDownloadsJSON,
shareSettingsJSON,
link.Version,
)
if err != nil {
return fmt.Errorf("failed to save share: %w", err)
}
@@ -320,6 +299,7 @@ func (s *SQLStore) scanShares(rows *sql.Rows) ([]*share.Share, error) {
&link.Expire,
&link.Downloads,
&link.PasswordHash,
&link.Token,
&userDownloadsJSON,
&shareSettingsJSON,
&link.Version,
@@ -1,81 +0,0 @@
package sqldb
import (
"database/sql"
"fmt"
)
const shareSelectColumns = `hash, user_id, source, path, expire, downloads,
password_hash, user_downloads, share_settings, version`
func sharesTableHasColumn(db *sql.DB, column string) (bool, error) {
rows, err := db.Query(`PRAGMA table_info(shares)`)
if err != nil {
return false, fmt.Errorf("read shares schema: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
cid int
name string
colType string
notnull int
dfltValue sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &colType, &notnull, &dfltValue, &pk); err != nil {
return false, fmt.Errorf("scan shares schema: %w", err)
}
if name == column {
return true, nil
}
}
if err := rows.Err(); err != nil {
return false, fmt.Errorf("iterate shares schema: %w", err)
}
return false, nil
}
// normalizeLegacyShareTokens keeps rollback compatibility with releases that still
// scan shares.token into a Go string (NULL is unsupported there).
func normalizeLegacyShareTokens(db *sql.DB) error {
hasToken, err := sharesTableHasColumn(db, "token")
if err != nil {
return err
}
if !hasToken {
return nil
}
if _, err := db.Exec(`UPDATE shares SET token = '' WHERE token IS NULL`); err != nil {
return fmt.Errorf("normalize legacy share tokens: %w", err)
}
return nil
}
func (s *SQLStore) sharesHasLegacyTokenColumn() (bool, error) {
return sharesTableHasColumn(s.db, "token")
}
func (s *SQLStore) legacyShareToken(hash string) (string, error) {
hasToken, err := s.sharesHasLegacyTokenColumn()
if err != nil {
return "", err
}
if !hasToken {
return "", nil
}
var token sql.NullString
err = s.db.QueryRow(`SELECT token FROM shares WHERE hash = ?`, hash).Scan(&token)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", fmt.Errorf("read legacy share token: %w", err)
}
if !token.Valid {
return "", nil
}
return token.String, nil
}
@@ -1,171 +0,0 @@
package sqldb
import (
"database/sql"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
)
func createLegacySharesDB(t *testing.T) *SQLStore {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "legacy-shares.db")
store, _, err := NewSQLStore(dbPath)
if err != nil {
t.Fatalf("NewSQLStore: %v", err)
}
_, err = store.db.Exec(`DROP TABLE shares`)
if err != nil {
t.Fatalf("drop shares: %v", err)
}
_, err = store.db.Exec(`
CREATE TABLE shares (
hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
source TEXT NOT NULL,
path TEXT NOT NULL,
expire INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0,
password_hash TEXT,
token TEXT,
user_downloads TEXT,
share_settings TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 0
)`)
if err != nil {
t.Fatalf("create legacy shares table: %v", err)
}
_, err = store.db.Exec(`
INSERT INTO shares (
hash, user_id, source, path, expire, downloads, password_hash, token,
user_downloads, share_settings, version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"legacy-null-token",
"1",
"/data",
"/docs/file.txt",
0,
0,
"",
nil,
"{}",
`{"shareType":"normal"}`,
1,
)
if err != nil {
t.Fatalf("insert legacy share: %v", err)
}
return store
}
func scanLegacyShareToken(t *testing.T, store *SQLStore, hash string) string {
t.Helper()
var token string
err := store.db.QueryRow(`
SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE hash = ?`, hash).Scan(
new(string),
new(string),
new(string),
new(string),
new(int64),
new(int),
new(string),
&token,
new([]byte),
new([]byte),
new(int),
)
if err != nil {
t.Fatalf("scan legacy share row: %v", err)
}
return token
}
func TestMigrationNormalizesNullLegacyShareToken(t *testing.T) {
store := createLegacySharesDB(t)
if err := runMigrations(store.db, 1); err != nil {
t.Fatalf("runMigrations: %v", err)
}
token := scanLegacyShareToken(t, store, "legacy-null-token")
if token != "" {
t.Fatalf("token = %q, want empty string after migration", token)
}
links, err := store.ListAllShares()
if err != nil {
t.Fatalf("ListAllShares: %v", err)
}
if len(links) != 1 || links[0].Hash != "legacy-null-token" {
t.Fatalf("unexpected shares loaded: %#v", links)
}
}
func TestSaveSharePreservesLegacyTokenColumn(t *testing.T) {
store := createLegacySharesDB(t)
if err := normalizeLegacyShareTokens(store.db); err != nil {
t.Fatalf("normalizeLegacyShareTokens: %v", err)
}
_, err := store.db.Exec(`UPDATE shares SET token = ? WHERE hash = ?`, "keep-me", "legacy-null-token")
if err != nil {
t.Fatalf("seed legacy token: %v", err)
}
link, err := store.GetShareByHash("legacy-null-token")
if err != nil {
t.Fatalf("GetShareByHash: %v", err)
}
link.Downloads = 2
if err := store.SaveShare(link); err != nil {
t.Fatalf("SaveShare: %v", err)
}
token := scanLegacyShareToken(t, store, "legacy-null-token")
if token != "keep-me" {
t.Fatalf("token = %q, want keep-me", token)
}
var tokenSQL sql.NullString
if err := store.db.QueryRow(`SELECT token FROM shares WHERE hash = ?`, "legacy-null-token").Scan(&tokenSQL); err != nil {
t.Fatalf("select token: %v", err)
}
if !tokenSQL.Valid {
t.Fatal("expected legacy token column to remain non-null after save")
}
}
func TestSaveShareSetsEmptyLegacyTokenForNewShare(t *testing.T) {
store := createLegacySharesDB(t)
if err := normalizeLegacyShareTokens(store.db); err != nil {
t.Fatalf("normalizeLegacyShareTokens: %v", err)
}
newShare := &share.Share{
ShareSettings: share.ShareSettings{
FrontendShareInfo: share.FrontendShareInfo{ShareType: "normal"},
},
ShareColumns: share.ShareColumns{
Hash: "brand-new-share",
Path: "/new/file.txt",
},
SourcePath: "/data",
UserID: 1,
Version: 1,
}
if err := store.SaveShare(newShare); err != nil {
t.Fatalf("SaveShare: %v", err)
}
token := scanLegacyShareToken(t, store, "brand-new-share")
if token != "" {
t.Fatalf("token = %q, want empty string for new share", token)
}
}
+3 -3
View File
@@ -94,7 +94,7 @@ func TestUpdateUserScopesAddsMissingSidebarLink(t *testing.T) {
}
}
func TestUpdateUserScopesPrunesSidebarLinks(t *testing.T) {
func TestUpdateUserScopesKeepsStaleSidebarLinks(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
@@ -127,8 +127,8 @@ func TestUpdateUserScopesPrunesSidebarLinks(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 1 {
t.Fatalf("expected revoked include link pruned, got %d links: %#v", count, loaded.SidebarLinks)
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 2 {
t.Fatalf("expected stale include link kept, got %d links: %#v", count, loaded.SidebarLinks)
}
}
-3
View File
@@ -28,9 +28,6 @@ func FrontendLinks(links []users.SidebarLink, showToolsInSidebar bool) []users.S
}
}
link.SourceName = source.Name
if link.Name == "" {
link.Name = source.Name
}
} else if link.Category == "tool" && link.Target == "/tools" {
hasTools = true
}
@@ -1,27 +0,0 @@
package usersidebar
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestFrontendLinks_populatesEmptySourceName(t *testing.T) {
testSourceConfig(t)
in := []users.SidebarLink{
{Category: "source", SourceName: ".", Target: "/"},
{Name: "My Vault", Category: string(users.SidebarLinkSourceMinimal), Icon: "folder", SourceName: "../frontend/tests/playwright-files", Target: "/docs"},
}
out := FrontendLinks(in, false)
if len(out) != 2 {
t.Fatalf("len(out) = %d, want 2", len(out))
}
if out[0].Name != "docker" || out[0].SourceName != "docker" {
t.Fatalf("default source link = %#v", out[0])
}
if out[1].Name != "My Vault" || out[1].SourceName != "playwright + files" {
t.Fatalf("custom source link = %#v", out[1])
}
}
+2 -4
View File
@@ -9,8 +9,6 @@ import (
// NormalizeSidebarLinks canonicalizes persisted sidebar links for storage.
// Source links are resolved via ResolveSourceKey on SourceName, with Name as fallback.
// SourceName is set to the canonical backend path; custom Name, Icon, and Category are preserved.
// Empty Name means "use live source display name"; names matching the current source display name
// are blanked so renames propagate without overwriting custom labels.
// Unresolvable source links are dropped. Source links are deduped by canonical path plus target
// (first occurrence wins), so multiple folder shortcuts on one source are kept. Non-source links
// pass through; duplicate Tools links are deduped.
@@ -38,8 +36,8 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
normalized := link
normalized.Category = users.NormalizeSidebarLinkCategory(normalized.Category)
normalized.SourceName = source.Path
if strings.TrimSpace(normalized.Name) == source.Name {
normalized.Name = ""
if strings.TrimSpace(normalized.Name) == "" {
normalized.Name = source.Name
}
normalized.Target = normalizeSidebarTarget(normalized.Target)
+16 -37
View File
@@ -69,9 +69,9 @@ func TestNormalizeSidebarLinks_dedupesLinuxAndDockerPaths(t *testing.T) {
t.Fatalf("len(out) = %d, want 3", len(out))
}
want := []users.SidebarLink{
{Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
{Category: "source", SourceName: ".", Target: "/"},
{Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "access", Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
}
if !reflect.DeepEqual(out, want) {
t.Fatalf("got %#v, want %#v", out, want)
@@ -95,24 +95,8 @@ func TestNormalizeSidebarLinks_nameFallbackRemapsStalePath(t *testing.T) {
if out[0].SourceName != "../frontend/tests/playwright-files" {
t.Fatalf("SourceName = %q", out[0].SourceName)
}
if out[0].Name != "" {
t.Fatalf("Name = %q, want blanked default name", out[0].Name)
}
}
func TestNormalizeSidebarLinks_blanksDefaultSourceName(t *testing.T) {
testSourceConfig(t)
in := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
}
out, changed := NormalizeSidebarLinks(in)
if !changed {
t.Fatal("expected changed=true")
}
if out[0].Name != "" {
t.Fatalf("Name = %q, want blanked when matching source display name", out[0].Name)
if out[0].Name != "playwright + files" {
t.Fatalf("Name = %q", out[0].Name)
}
}
@@ -126,20 +110,18 @@ func TestNormalizeSidebarLinks_idempotentOnCanonicalLinks(t *testing.T) {
}
out, changed := NormalizeSidebarLinks(in)
if !changed {
t.Fatal("expected changed=true when blanking default names")
if changed {
t.Fatal("expected changed=false for canonical links")
}
for _, link := range out {
if link.Name != "" {
t.Fatalf("expected blank default name, got %#v", link)
}
if !reflect.DeepEqual(out, in) {
t.Fatalf("got %#v, want %#v", out, in)
}
out2, changed2 := NormalizeSidebarLinks(out)
if changed2 {
t.Fatal("expected second pass unchanged")
}
if !reflect.DeepEqual(out2, out) {
if !reflect.DeepEqual(out2, in) {
t.Fatalf("second pass got %#v", out2)
}
}
@@ -159,8 +141,8 @@ func TestNormalizeSidebarLinks_dropsUnresolvableSourceLink(t *testing.T) {
if len(out) != 1 {
t.Fatalf("len(out) = %d, want 1", len(out))
}
if out[0].Name != "" {
t.Fatalf("remaining link should have blanked default name: %#v", out[0])
if out[0].Name != "docker" {
t.Fatalf("remaining link = %#v", out[0])
}
}
@@ -193,15 +175,12 @@ func TestNormalizeSidebarLinks_preservesMultipleFolderShortcutsOnSameSource(t *t
}
out, changed := NormalizeSidebarLinks(in)
if !changed {
t.Fatal("expected changed=true when blanking default root source name")
if changed {
t.Fatal("expected canonical folder shortcuts unchanged")
}
if len(out) != 4 {
t.Fatalf("len(out) = %d, want 4", len(out))
}
if out[0].Name != "" {
t.Fatalf("root source link should have blank default name: %#v", out[0])
}
if out[1].Name != "Photos" || out[1].Icon != "photo" || out[1].Target != "/photos" {
t.Fatalf("photos link = %#v", out[1])
}
@@ -225,8 +204,8 @@ func TestNormalizeSidebarLinks_dedupesSameSourceAndTarget(t *testing.T) {
if len(out) != 1 {
t.Fatalf("len(out) = %d, want 1", len(out))
}
if out[0].Name != "" {
t.Fatalf("first link wins with blanked default name: %#v", out[0])
if out[0].Name != "docker" {
t.Fatalf("first link wins: %#v", out[0])
}
}
+2 -7
View File
@@ -4,8 +4,8 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
// PrepareSidebarLinksForPersist normalizes sidebar links, prunes links for revoked scopes,
// and adds missing scoped source entries. Sources absent from config are dropped by NormalizeSidebarLinks.
// PrepareSidebarLinksForPersist normalizes sidebar links and adds missing scoped source entries.
// Inaccessible sources are not pruned; callers rely on the UI to gray out stale links.
func PrepareSidebarLinksForPersist(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
updated := false
@@ -14,11 +14,6 @@ func PrepareSidebarLinksForPersist(links []users.SidebarLink, scopes []users.Bac
updated = true
}
if pruned, changed := PruneSidebarLinksForScopes(links, scopes); changed {
links = pruned
updated = true
}
merged, changed := EnsureSidebarLinksFromScopes(links, scopes)
if changed {
links = merged
+7 -7
View File
@@ -28,12 +28,12 @@ func TestPrepareSidebarLinksForPersist_addsScopedSourcesAndPreservesFolders(t *t
if out[1].Name != "Photos" || out[1].Icon != "photo" {
t.Fatalf("folder shortcut lost: %#v", out[1])
}
if out[2].Name != "" {
t.Fatalf("added scope link should have empty default name, got %#v", out[2])
if out[2].Name != "playwright + files" {
t.Fatalf("added scope link = %#v", out[2])
}
}
func TestPrepareSidebarLinksForPersist_prunesLinksWhenScopeRemoved(t *testing.T) {
func TestPrepareSidebarLinksForPersist_keepsLinksWhenScopeRemoved(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
@@ -46,10 +46,10 @@ func TestPrepareSidebarLinksForPersist_prunesLinksWhenScopeRemoved(t *testing.T)
}
out, changed := PrepareSidebarLinksForPersist(links, scopes)
if !changed {
t.Fatal("expected changed=true when revoked scope link is pruned")
if changed {
t.Fatal("expected changed=false when only pruning is not performed")
}
if len(out) != 2 {
t.Fatalf("len(out) = %d, want 2 (revoked scope link removed)", len(out))
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3 (stale scope link kept)", len(out))
}
}
-37
View File
@@ -1,37 +0,0 @@
package usersidebar
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
// PruneSidebarLinksForScopes removes source-type links whose source is not in the user's scopes.
// Non-source links (tool, custom, divider) are preserved.
func PruneSidebarLinksForScopes(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
if !users.SourceConfigLoaded() || len(scopes) == 0 {
return links, false
}
allowed := make(map[string]struct{}, len(scopes))
for _, path := range uniqueScopedSourcePaths(scopes) {
allowed[path] = struct{}{}
}
out := make([]users.SidebarLink, 0, len(links))
changed := false
for _, link := range links {
if users.IsSourceSidebarCategory(link.Category) {
source, ok := resolveSourceLink(link)
if !ok {
changed = true
continue
}
if _, ok := allowed[source.Path]; !ok {
changed = true
continue
}
}
out = append(out, link)
}
return out, changed
}
@@ -1,51 +0,0 @@
package usersidebar
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestPruneSidebarLinksForScopes_removesRevokedSourceAndFolderShortcut(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
{Name: "Docs", Category: string(users.SidebarLinkCustom), Target: "https://example.com", Icon: "link"},
}
scopes := []users.BackendScope{
{Path: "."},
}
out, changed := PruneSidebarLinksForScopes(links, scopes)
if !changed {
t.Fatal("expected changed=true")
}
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3 (playwright link removed, docker + folder + custom kept)", len(out))
}
if out[2].Category != string(users.SidebarLinkCustom) {
t.Fatalf("custom link lost: %#v", out[2])
}
}
func TestPruneSidebarLinksForScopes_noChangeWhenAllScoped(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
}
scopes := []users.BackendScope{
{Path: "."},
}
out, changed := PruneSidebarLinksForScopes(links, scopes)
if changed {
t.Fatal("expected changed=false")
}
if len(out) != 1 {
t.Fatalf("len(out) = %d, want 1", len(out))
}
}
+1
View File
@@ -26,6 +26,7 @@ func EnsureSidebarLinksFromScopes(links []users.SidebarLink, scopes []users.Back
continue
}
out = append(out, users.SidebarLink{
Name: source.Name,
Category: string(users.SidebarLinkSource),
Target: "/",
SourceName: source.Path,
+2 -2
View File
@@ -67,8 +67,8 @@ func TestEnsureSidebarLinksFromScopes_addsMissingScopedSources(t *testing.T) {
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3", len(out))
}
if out[2].Name != "" {
t.Fatalf("added link should have empty default name, got %#v", out[2])
if out[2].Name != "playwright + files" {
t.Fatalf("added link = %#v", out[2])
}
if out[1].Category != string(users.SidebarLinkCustom) {
t.Fatalf("custom link lost: %#v", out[1])
-1
View File
@@ -14,5 +14,4 @@ var (
JwtCache = cache.NewCache[string](1*time.Hour, 72*time.Hour)
ViewGrantsCache = cache.NewCache[ViewGrant](15 * time.Minute)
ViewGrantIndex = cache.NewCache[string](15 * time.Minute)
ShareAccessGrantsCache = cache.NewCache[ShareAccessGrant](24 * time.Hour)
)
-33
View File
@@ -1,14 +1,12 @@
package utils
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"fmt"
"strings"
"golang.org/x/crypto/bcrypt"
)
@@ -58,37 +56,6 @@ func HashSHA256(data string) string {
return hex.EncodeToString(bytes[:])
}
// SignHMACSHA256Base64URL returns base64url(payload).base64url(hmac-sha256 signature).
func SignHMACSHA256Base64URL(key, payload []byte) string {
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payloadB64))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return payloadB64 + "." + signature
}
// VerifyHMACSHA256Base64URL validates token form and returns the decoded payload.
func VerifyHMACSHA256Base64URL(key []byte, token string) ([]byte, bool) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return nil, false
}
payloadB64, signature := parts[0], parts[1]
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payloadB64))
expectedSignature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
return nil, false
}
payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
if err != nil {
return nil, false
}
return payload, true
}
// RandomUint64ID returns a non-zero cryptographically random uint64 (e.g. new user rows after migration).
// Small ids 1, 2, 3, … remain valid for migrated Bolt-era users.
func RandomUint64ID() (uint64, error) {
-5
View File
@@ -6,8 +6,3 @@ type ViewGrant struct {
Source string
ExpiresAt int64
}
// ShareAccessGrant tracks remaining uses for count-limited share download tokens.
type ShareAccessGrant struct {
RemainingUses int
}
+33 -77
View File
@@ -1,11 +1,13 @@
package web
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
libError "errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
@@ -365,23 +367,40 @@ func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int,
return 0, nil
}
// AuthenticateShareRequest validates access to a password-protected share.
// UI sessions are minted only after successful X-SHARE-PASSWORD auth, never from download tokens.
func AuthenticateShareRequest(w http.ResponseWriter, r *http.Request, l share.Share) (int, error) {
func AuthenticateShareRequest(r *http.Request, l share.Share) (int, error) {
if l.PasswordHash == "" {
return http.StatusOK, nil
}
if validateShareUISessionCookie(r, l.Hash) {
return http.StatusOK, nil
return 200, nil
}
tokenParam := r.URL.Query().Get("token")
if tokenParam != "" {
if shareRequestAllowsDownloadToken(r) && authorizeShareDownloadAccessToken(tokenParam, l.Hash) {
return http.StatusOK, nil
// Verify the token signature if it's in the new signed format
if strings.Contains(tokenParam, ".") {
parts := strings.Split(tokenParam, ".")
if len(parts) == 2 {
payload := parts[0]
signature := parts[1]
// Verify HMAC signature
mac := hmac.New(sha256.New, []byte(settings.Config.Auth.Key))
mac.Write([]byte(payload))
expectedSignature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
// Use constant-time comparison to prevent timing attacks
if hmac.Equal([]byte(signature), []byte(expectedSignature)) {
// Token signature is valid, now check if it matches stored token
if tokenParam == l.Token {
return 200, nil
}
}
}
} else {
// Legacy token format (plain base64) - direct comparison
if tokenParam == l.Token {
return 200, nil
}
}
logger.Debugf("share auth failed: hash=%s reason=invalid_or_disallowed_token", l.Hash)
logger.Debugf("share auth failed: hash=%s reason=invalid_token", l.Hash)
}
password := r.Header.Get("X-SHARE-PASSWORD")
@@ -389,80 +408,17 @@ func AuthenticateShareRequest(w http.ResponseWriter, r *http.Request, l share.Sh
logger.Debugf("share auth failed: hash=%s reason=missing_password", l.Hash)
return http.StatusUnauthorized, nil
}
password, err := url.QueryUnescape(password)
if err != nil {
return http.StatusUnauthorized, nil
}
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
logger.Debugf("share auth failed: hash=%s reason=wrong_password", l.Hash)
return http.StatusUnauthorized, nil
}
return http.StatusUnauthorized, err
return 401, err
}
if w != nil {
if cookieErr := SetShareUISessionCookie(w, r, l.Hash); cookieErr != nil {
logger.Debugf("share session cookie: hash=%s err=%v", l.Hash, cookieErr)
}
}
return http.StatusOK, nil
return 200, nil
}
const sessionCookieName = "filebrowser_quantum_jwt"
const shareUISessionCookieName = "filebrowser_share_session"
// validateShareUISessionCookie checks the HttpOnly share UI session cookie for a hash.
func validateShareUISessionCookie(r *http.Request, shareHash string) bool {
if r == nil || shareHash == "" {
return false
}
cookie, err := r.Cookie(shareUISessionCookieName)
if err != nil || cookie.Value == "" {
return false
}
return validateShareUISessionToken(cookie.Value, shareHash)
}
// SetShareUISessionCookie stores a short-lived session after successful X-SHARE-PASSWORD auth.
// Enables native browser streaming downloads without putting tokens in download URLs.
func SetShareUISessionCookie(w http.ResponseWriter, r *http.Request, shareHash string) error {
token, expiresAt, err := mintShareUISessionToken(shareHash, maxShareUISessionTTL)
if err != nil {
return err
}
expiresTime := time.Unix(expiresAt, 0)
http.SetCookie(w, shareUISessionCookie(r, token, expiresTime))
return nil
}
const maxShareUISessionTTL = 24 * time.Hour
// shareUISessionCookiePath scopes the share session cookie to the configured HTTP base URL path.
func shareUISessionCookiePath() string {
base := settings.Config.Http.BaseURL
if base == "" {
return "/"
}
return base
}
// shareUISessionCookie builds the HttpOnly share UI session cookie for a validated password entry.
func shareUISessionCookie(r *http.Request, token string, expiresTime time.Time) *http.Cookie {
maxAge := int(time.Until(expiresTime).Seconds())
if maxAge < 0 {
maxAge = 0
}
return &http.Cookie{
Name: shareUISessionCookieName,
Value: token,
Path: shareUISessionCookiePath(),
SameSite: http.SameSiteLaxMode,
HttpOnly: true,
Secure: requestScheme(r) == "https",
Expires: expiresTime,
MaxAge: maxAge,
}
}
// sessionCookie builds the JWT cookie. HttpOnly prevents srcdoc XSS from reading
// document.cookie; Secure is set when the request is HTTPS (including via proxy).
+2 -1
View File
@@ -83,7 +83,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
// Authenticate the share request if needed
var status int
if link.Hash != "" {
status, err = AuthenticateShareRequest(w, r, link)
status, err = AuthenticateShareRequest(r, link)
if err != nil || status != http.StatusOK {
return status, fmt.Errorf("could not authenticate share request")
}
@@ -149,6 +149,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
return ErrToStatus(err), fmt.Errorf("error fetching share from server")
}
file.Token = link.Token
file.Source = link.Hash
file.Hash = link.Hash
if !link.EnableOnlyOffice || link.DisableFileViewer || reachedDownloadsLimit {
+14 -31
View File
@@ -322,7 +322,6 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
setupTestEnv(t)
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe" // bcrypt hashed password
const accentedPasswordBcrypt = "$2b$04$JaVUkztbmWKgV8GsSvRorettpNHrc0BvYV3/oARpepfoanKbqt5zi"
// Create and save a dummy user (shares reference owner by UserID)
dummyUser := &users.User{
@@ -364,7 +363,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
},
{
name: "Private share, valid password",
name: "Private share, valid password when token exists",
share: &share.Share{
ShareSettings: share.ShareSettings{
ShareLimits: share.ShareLimits{SourceName: "srv"},
@@ -373,6 +372,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
SourcePath: "/srv",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "some_random_token",
},
extraHeaders: map[string]string{
"X-SHARE-PASSWORD": "password",
@@ -389,11 +389,12 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
SourcePath: "/srv",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "123",
},
expectedStatusCode: http.StatusUnauthorized,
},
{
name: "Private share, download token rejected on listing route",
name: "Private share, valid token",
share: &share.Share{
ShareSettings: share.ShareSettings{
ShareLimits: share.ShareLimits{SourceName: "srv"},
@@ -402,9 +403,10 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
SourcePath: "/srv",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "123",
},
token: "download-token-placeholder",
expectedStatusCode: http.StatusUnauthorized,
token: "123",
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
},
{
name: "Private share, invalid password",
@@ -416,49 +418,30 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
SourcePath: "/srv",
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "123",
},
extraHeaders: map[string]string{
"X-SHARE-PASSWORD": "wrong-password",
},
expectedStatusCode: http.StatusUnauthorized,
},
{
name: "Private share, valid encoded non-ASCII password",
share: &share.Share{
ShareSettings: share.ShareSettings{
ShareLimits: share.ShareLimits{SourceName: "srv"},
},
ShareColumns: share.ShareColumns{Hash: "accented_password_hash", Path: "/"},
SourcePath: "/srv",
UserID: 1,
PasswordHash: accentedPasswordBcrypt,
},
extraHeaders: map[string]string{
"X-SHARE-PASSWORD": "pass%C3%A9",
},
expectedStatusCode: http.StatusOK,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Save the share in the state
if err := state.CreateShare(tc.share); err != nil {
t.Fatal("failed to save share:", err)
}
// Create a response recorder to capture handler output
recorder := httptest.NewRecorder()
// Wrap the handler with authentication middleware
handler := withHashFile(publicGetResourceHandler)
token := tc.token
if tc.name == "Private share, download token rejected on listing route" {
minted, _, err := mintShareDownloadAccessToken(tc.share.Hash, time.Hour, 0)
if err != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", err)
}
token = minted
}
req := newTestRequest(t, tc.share.Hash, token, tc.password, tc.extraHeaders)
// Prepare the request with query parameters and optional headers
req := newTestRequest(t, tc.share.Hash, tc.token, tc.password, tc.extraHeaders)
// Serve the request
handler(recorder, req)
+4 -8
View File
@@ -298,17 +298,13 @@ func joinOnlyOfficeAPIURL(baseURL, apiPath string) string {
return base + "/" + path
}
// shareTokenForOnlyOffice mints a short-lived download token for OnlyOffice server URLs.
// shareTokenForOnlyOffice returns the share download token for OnlyOffice server URLs.
// Only password-protected shares have a token; callers must already have passed share auth.
func shareTokenForOnlyOffice(d *Context) string {
if d.Share.Hash == "" || d.Share.PasswordHash == "" {
if d.Share.Hash == "" || d.Share.PasswordHash == "" || d.Share.Token == "" {
return ""
}
token, _, err := mintShareDownloadAccessToken(d.Share.Hash, 2*time.Hour, 0)
if err != nil {
logger.Errorf("failed to mint OnlyOffice share download token: hash=%s error=%v", d.Share.Hash, err)
return ""
}
return token
return d.Share.Token
}
// buildOnlyOfficeViewURL constructs the view URL that OnlyOffice server uses to fetch the document.
+2 -2
View File
@@ -83,7 +83,7 @@ func requestPageFullURL(r *http.Request) string {
}
// ShareURLFromRequest builds a public share or direct-download URL from the request and server config.
func ShareURLFromRequest(r *http.Request, hash string, isDirectDownload bool, hasPassword bool) string {
func ShareURLFromRequest(r *http.Request, hash string, isDirectDownload bool, token string) string {
host, scheme := shareURLParams(r)
return share.PublicShareURL(host, scheme, hash, isDirectDownload, hasPassword)
return share.PublicShareURL(host, scheme, hash, isDirectDownload, token)
}
+168 -84
View File
@@ -1,7 +1,9 @@
package web
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
@@ -258,7 +260,19 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return status, err2
}
stringHash := ""
var token string
if len(hash) > 0 {
payloadBuffer := make([]byte, 24)
if _, err = rand.Read(payloadBuffer); err != nil {
return http.StatusInternalServerError, err
}
payload := base64.URLEncoding.EncodeToString(payloadBuffer)
mac := hmac.New(sha256.New, []byte(settings.Config.Auth.Key))
mac.Write([]byte(payload))
signature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
token = payload + "." + signature
stringHash = string(hash)
}
if req.Hash != "" {
@@ -281,7 +295,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
shouldResetCounts := link.DownloadsLimit != req.DownloadsLimit ||
link.PerUserDownloadLimit != req.PerUserDownloadLimit
if err = applySharePasswordUpdate(link, req.Password, stringHash); err != nil {
if err = applySharePasswordUpdate(link, req.Password, stringHash, token); err != nil {
return err
}
@@ -378,6 +392,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
SourcePath: source.Path,
UserID: d.User.ID,
PasswordHash: stringHash,
Token: token,
Version: 1,
}
s.DownloadURL = ""
@@ -406,107 +421,174 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return RenderJSON(w, r, prepared)
}
// DirectDownloadResponse represents the response for direct download endpoint.
// DirectDownloadResponse represents the response for direct download endpoint
type DirectDownloadResponse struct {
Hash string `json:"hash"`
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
URL string `json:"url"`
Status string `json:"status"`
Hash string `json:"hash"`
DownloadURL string `json:"url"`
ShareURL string `json:"shareUrl"`
}
// parseShareDownloadTokenParams reads duration, unit, and optional use-count from the direct-download API query.
func parseShareDownloadTokenParams(r *http.Request) (time.Duration, int, error) {
durationStr := r.URL.Query().Get("duration")
if durationStr == "" {
durationStr = "60"
}
durationNum, err := strconv.Atoi(durationStr)
if err != nil {
return 0, 0, fmt.Errorf("invalid duration: %v", err)
}
unit := r.URL.Query().Get("unit")
if unit == "" {
unit = "minutes"
}
ttl, err := parseShareDownloadAccessDuration(durationNum, unit)
if err != nil {
return 0, 0, err
}
maxUses := 0
if countStr := r.URL.Query().Get("count"); countStr != "" {
maxUses, err = strconv.Atoi(countStr)
if err != nil || maxUses <= 0 {
return 0, 0, fmt.Errorf("invalid count: %v", err)
}
}
return ttl, maxUses, nil
}
// mintShareDownloadTokenResponse builds the JSON payload for GET /api/share/direct.
func mintShareDownloadTokenResponse(r *http.Request, hash string, ttl time.Duration, maxUses int) (DirectDownloadResponse, error) {
token, expiresAt, err := mintShareDownloadAccessToken(hash, ttl, maxUses)
if err != nil {
return DirectDownloadResponse{}, err
}
host, scheme := shareURLParams(r)
return DirectDownloadResponse{
Hash: hash,
Token: token,
ExpiresAt: expiresAt,
URL: directDownloadURL(host, scheme, hash, token),
}, nil
}
// shareDirectDownloadHandler mints a download-only ephemeral token for a password-protected share.
// @Summary Get direct download link
// @Description Mints a download-only ephemeral token for an existing password-protected share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes. Maximum token lifetime is 24 hours regardless of unit.
// shareDirectDownloadHandler creates a direct download link for files only.
// @Summary Create direct download link
// @Description Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash"
// @Param duration query string false "Duration value (default: 60)"
// @Param unit query string false "Duration unit: minutes or hours (default: minutes)"
// @Param count query string false "Maximum number of downloads allowed with this token (default: unlimited within TTL)"
// @Param X-SHARE-PASSWORD header string true "Share password"
// @Success 200 {object} DirectDownloadResponse "Ephemeral download link"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 401 {object} map[string]string "Invalid or missing share password"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Share not found"
// @Param path query string true "File path to create download link for"
// @Param source query string true "Source name for the file"
// @Param duration query string false "Duration in minutes for link validity (default: 60)"
// @Param count query string false "Maximum number of downloads allowed (default: unlimited)"
// @Param speed query string false "Download speed limit in kbps (default: unlimited)"
// @Success 201 {object} DirectDownloadResponse "Direct download link created"
// @Failure 400 {object} map[string]string "Bad request - invalid parameters or path is not a file"
// @Failure 403 {object} map[string]string "Forbidden - access denied"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/share/direct [get]
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
hash := r.URL.Query().Get("hash")
if hash == "" {
return http.StatusBadRequest, fmt.Errorf("hash is required")
// Extract query parameters
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
duration := r.URL.Query().Get("duration")
downloadCountStr := r.URL.Query().Get("count")
downloadSpeedStr := r.URL.Query().Get("speed")
// Validate required parameters
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
link, err := state.GetShare(hash)
cleanPath, err := utils.SanitizePath(path)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share not found")
return http.StatusBadRequest, fmt.Errorf("invalid path: %w", err)
}
if !link.HasPassword() {
return http.StatusBadRequest, fmt.Errorf("share is not password protected")
}
if !link.UserCanEdit(d.User) {
return http.StatusForbidden, fmt.Errorf("you are not allowed to mint tokens for this share")
path = cleanPath
// Validate source exists
sourceInfo, ok := settings.Config.Server.NameToSource[source]
if !ok {
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", source)
}
status, err := AuthenticateShareRequest(w, r, link)
if err != nil || status != http.StatusOK {
return http.StatusUnauthorized, fmt.Errorf("invalid share password")
}
ttl, maxUses, err := parseShareDownloadTokenParams(r)
// Get user scope for this source
userscope, err := d.User.GetScopeForSourceName(source)
if err != nil {
return http.StatusBadRequest, err
return http.StatusForbidden, err
}
response, err := mintShareDownloadTokenResponse(r, hash, ttl, maxUses)
// Validate the path exists and is a file (not a folder)
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", source)
}
metadata, exists := idx.GetReducedMetadata(path, false)
if !exists {
return http.StatusBadRequest, fmt.Errorf("path is either not a file or not found: %s", path)
}
// Check if it's a file (not a directory)
if metadata.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("path must be a file, not a directory: %s", path)
}
// Set default duration to 60 minutes if not provided
if duration == "" {
duration = "60"
}
// Parse download count
var downloadCount int
if downloadCountStr != "" {
downloadCount, err = strconv.Atoi(downloadCountStr)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid downloadCount: %v", err)
}
}
// Parse download speed (in bytes per second)
var downloadSpeed int
if downloadSpeedStr != "" {
downloadSpeed, err = strconv.Atoi(downloadSpeedStr)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid downloadSpeed: %v", err)
}
}
// Calculate expiration time
durationNum, err := strconv.Atoi(duration)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid duration: %v", err)
}
expire := time.Now().Add(time.Minute * time.Duration(durationNum)).Unix()
// Generate secure hash for the share
secureHash, err := generateShortUUID()
if err != nil {
return http.StatusInternalServerError, err
}
// Create the scope path (file: no trailing slash; matches share cache normalization)
scopePath := utils.JoinPathAsUnix(userscope, path)
// Check if an existing share already matches these parameters
existingShares, err := state.GetSharesInScope(scopePath, sourceInfo.Path, d.User.ID)
if err == nil && len(existingShares) > 0 {
for _, existing := range existingShares {
if existing.DownloadsLimit == downloadCount &&
existing.MaxBandwidth == downloadSpeed &&
existing.QuickDownload &&
(existing.Expire == 0 || existing.Expire >= expire) { // Existing expires later or never
response := DirectDownloadResponse{
Status: "201",
Hash: existing.Hash,
DownloadURL: ShareURLFromRequest(r, existing.Hash, true, existing.Token),
ShareURL: ShareURLFromRequest(r, existing.Hash, false, existing.Token),
}
return RenderJSON(w, r, response)
}
}
}
// No matching existing share found, create a new one
shareLink := &share.Share{
ShareSettings: share.ShareSettings{
FrontendShareInfo: share.FrontendShareInfo{
QuickDownload: true,
},
ShareLimits: share.ShareLimits{
MaxBandwidth: downloadSpeed,
DownloadsLimit: downloadCount,
SourceName: sourceInfo.Name,
},
},
ShareColumns: share.ShareColumns{
Hash: secureHash,
Path: scopePath,
Expire: expire,
},
SourcePath: idx.Path,
UserID: d.User.ID,
Version: 1,
}
if err = state.CreateShare(shareLink); err != nil {
return http.StatusInternalServerError, err
}
snap, err := state.GetShare(secureHash)
if err != nil {
return http.StatusInternalServerError, err
}
response := DirectDownloadResponse{
Status: "200",
Hash: secureHash,
DownloadURL: ShareURLFromRequest(r, secureHash, true, snap.Token),
ShareURL: ShareURLFromRequest(r, secureHash, false, snap.Token),
}
activity.RecordShareMutation(r, toActor(d), activitydb.EventShareCreate, secureHash, snap.SourceName, snap.Path, nil)
return RenderJSON(w, r, response)
}
@@ -595,7 +677,7 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
frontendShareInfo := shareInfo.FrontendShareInfo
frontendShareInfo.ShareURL = ShareURLFromRequest(r, hash, false, shareInfo.HasPassword())
frontendShareInfo.ShareURL = ShareURLFromRequest(r, hash, false, "")
frontendShareInfo.BannerUrl = shareInfo.BannerURL()
frontendShareInfo.FaviconUrl = shareInfo.FaviconURL()
filtered := make([]users.SidebarLink, 0, len(frontendShareInfo.SidebarLinks))
@@ -740,15 +822,17 @@ func sharePasswordFromRequest(password *string) ([]byte, int, error) {
// applySharePasswordUpdate sets or preserves password credentials on share update.
// Nil password omits the field (keep existing); empty string clears; non-empty replaces.
func applySharePasswordUpdate(link *share.Share, password *string, hashedPassword string) error {
func applySharePasswordUpdate(link *share.Share, password *string, hashedPassword, token string) error {
if password == nil {
return nil
}
if *password == "" {
link.PasswordHash = ""
link.Token = ""
return nil
}
link.PasswordHash = hashedPassword
link.Token = token
return nil
}
-239
View File
@@ -1,239 +0,0 @@
package web
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
const (
shareAccessScopeDownload = "download"
shareAccessScopeUISession = "ui"
maxShareAccessTTL = 24 * time.Hour
)
// shareDownloadGrantMu serializes use-count decrements for limited download tokens.
var shareDownloadGrantMu sync.Mutex
type shareAccessClaims struct {
Hash string `json:"h"`
Exp int64 `json:"exp"`
Scope string `json:"s"`
ID string `json:"id,omitempty"`
}
// parseShareAccessDuration converts API duration parameters into a time.Duration.
func parseShareAccessDuration(duration int, unit string) (time.Duration, error) {
if duration <= 0 {
return 0, fmt.Errorf("duration must be positive")
}
switch unit {
case "minutes", "minute", "":
return time.Duration(duration) * time.Minute, nil
case "hours", "hour":
return time.Duration(duration) * time.Hour, nil
default:
return 0, fmt.Errorf("invalid unit: %s", unit)
}
}
// parseShareDownloadAccessDuration validates share direct-download TTL capped at 24 hours.
func parseShareDownloadAccessDuration(duration int, unit string) (time.Duration, error) {
ttl, err := parseShareAccessDuration(duration, unit)
if err != nil {
return 0, err
}
if ttl > maxShareAccessTTL {
return 0, fmt.Errorf("duration exceeds maximum of 24 hours")
}
return ttl, nil
}
// shareAccessAuthKey returns the HMAC signing key for share access tokens.
func shareAccessAuthKey() []byte {
return []byte(settings.Config.Auth.Key)
}
// mintSignedShareAccessToken signs share access claims as a base64url.payload.signature token.
func mintSignedShareAccessToken(claims shareAccessClaims) (token string, expiresAt int64, err error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", 0, err
}
return utils.SignHMACSHA256Base64URL(shareAccessAuthKey(), payload), claims.Exp, nil
}
// verifySignedShareAccessToken parses and verifies a signed share access token payload.
func verifySignedShareAccessToken(tokenParam string) (*shareAccessClaims, bool) {
payload, ok := utils.VerifyHMACSHA256Base64URL(shareAccessAuthKey(), tokenParam)
if !ok {
return nil, false
}
var claims shareAccessClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, false
}
return &claims, true
}
// mintShareDownloadAccessToken creates a signed, download-scoped ephemeral token for a share.
func mintShareDownloadAccessToken(shareHash string, ttl time.Duration, maxUses int) (token string, expiresAt int64, err error) {
if shareHash == "" {
return "", 0, fmt.Errorf("share hash is required")
}
if ttl <= 0 || ttl > maxShareAccessTTL {
return "", 0, fmt.Errorf("invalid token duration")
}
if maxUses < 0 {
return "", 0, fmt.Errorf("invalid max uses")
}
expiresAt = time.Now().Add(ttl).Unix()
claims := shareAccessClaims{
Hash: shareHash,
Exp: expiresAt,
Scope: shareAccessScopeDownload,
}
remainingUses := -1
if maxUses > 0 {
idBytes := make([]byte, 12)
if _, err = rand.Read(idBytes); err != nil {
return "", 0, err
}
claims.ID = base64.RawURLEncoding.EncodeToString(idBytes)
remainingUses = maxUses
}
token, expiresAt, err = mintSignedShareAccessToken(claims)
if err != nil {
return "", 0, err
}
if claims.ID != "" {
utils.ShareAccessGrantsCache.Set(claims.ID, utils.ShareAccessGrant{RemainingUses: remainingUses})
}
return token, expiresAt, nil
}
// mintShareUISessionToken creates a signed cookie-scoped token for UI share access after password entry.
func mintShareUISessionToken(shareHash string, ttl time.Duration) (token string, expiresAt int64, err error) {
if shareHash == "" {
return "", 0, fmt.Errorf("share hash is required")
}
if ttl <= 0 || ttl > maxShareAccessTTL {
return "", 0, fmt.Errorf("invalid token duration")
}
expiresAt = time.Now().Add(ttl).Unix()
return mintSignedShareAccessToken(shareAccessClaims{
Hash: shareHash,
Exp: expiresAt,
Scope: shareAccessScopeUISession,
})
}
// validateShareUISessionToken checks a UI session token for the given share hash.
func validateShareUISessionToken(tokenParam, shareHash string) bool {
claims, ok := verifySignedShareAccessToken(tokenParam)
if !ok || claims == nil {
return false
}
if claims.Scope != shareAccessScopeUISession || claims.Hash != shareHash {
return false
}
return time.Now().Unix() <= claims.Exp
}
// validateShareDownloadAccessToken checks a download token without consuming a limited use.
func validateShareDownloadAccessToken(tokenParam, shareHash string) bool {
claims, ok := verifySignedShareAccessToken(tokenParam)
if !ok || claims == nil {
return false
}
if claims.Scope != shareAccessScopeDownload || claims.Hash != shareHash {
return false
}
if time.Now().Unix() > claims.Exp {
return false
}
if claims.ID == "" {
return true
}
grant, ok := utils.ShareAccessGrantsCache.Get(claims.ID)
if !ok || grant.RemainingUses <= 0 {
return false
}
return true
}
// authorizeShareDownloadAccessToken validates a download token and atomically consumes one use when limited.
func authorizeShareDownloadAccessToken(tokenParam, shareHash string) bool {
claims, ok := verifySignedShareAccessToken(tokenParam)
if !ok || claims == nil {
return false
}
if claims.Scope != shareAccessScopeDownload || claims.Hash != shareHash {
return false
}
if time.Now().Unix() > claims.Exp {
return false
}
if claims.ID == "" {
return true
}
shareDownloadGrantMu.Lock()
defer shareDownloadGrantMu.Unlock()
grant, ok := utils.ShareAccessGrantsCache.Get(claims.ID)
if !ok || grant.RemainingUses <= 0 {
return false
}
grant.RemainingUses--
if grant.RemainingUses <= 0 {
utils.ShareAccessGrantsCache.Delete(claims.ID)
} else {
utils.ShareAccessGrantsCache.Set(claims.ID, grant)
}
return true
}
// shareRouteAllowsDownloadToken reports whether an ephemeral download token may authorize this path.
func shareRouteAllowsDownloadToken(path string) bool {
return strings.Contains(path, "/resources/download") ||
strings.Contains(path, "/resources/view") ||
strings.Contains(path, "/media/stream") ||
strings.Contains(path, "/raw")
}
// shareRequestAllowsDownloadToken reports whether an ephemeral download token may authorize this request.
func shareRequestAllowsDownloadToken(r *http.Request) bool {
if r == nil {
return false
}
return shareRouteAllowsDownloadToken(r.URL.Path)
}
// directDownloadURL builds a public download URL with an ephemeral token query parameter.
func directDownloadURL(host, scheme, hash, token string) string {
tokenParam := ""
if token != "" {
tokenParam = fmt.Sprintf("&token=%s", token)
}
if settings.Config.Http.ExternalUrl != "" {
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s%s",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash, tokenParam)
}
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s%s",
scheme, host, settings.Config.Http.BaseURL, hash, tokenParam)
}
-129
View File
@@ -1,129 +0,0 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestMintAndValidateShareDownloadAccessToken(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
token, expiresAt, err := mintShareDownloadAccessToken("share123", time.Hour, 0)
if err != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", err)
}
if token == "" || expiresAt <= time.Now().Unix() {
t.Fatalf("unexpected token response: token=%q expiresAt=%d", token, expiresAt)
}
if !validateShareDownloadAccessToken(token, "share123") {
t.Fatal("expected valid token for matching hash")
}
if validateShareDownloadAccessToken(token, "other-hash") {
t.Fatal("expected invalid token for different hash")
}
}
func TestShareDownloadTokenRejectedOnListingRoute(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
token, _, err := mintShareDownloadAccessToken("share123", time.Hour, 0)
if err != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/public/api/resources?hash=share123&token="+token, nil)
if shareRequestAllowsDownloadToken(req) {
t.Fatal("listing route should not allow download token auth")
}
downloadReq := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash=share123&token="+token, nil)
if !shareRequestAllowsDownloadToken(downloadReq) {
t.Fatal("download route should allow download token auth")
}
}
func TestAuthorizeShareDownloadAccessTokenUseCount(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
token, _, err := mintShareDownloadAccessToken("share123", time.Hour, 1)
if err != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", err)
}
if !authorizeShareDownloadAccessToken(token, "share123") {
t.Fatal("expected first authorization to succeed")
}
if authorizeShareDownloadAccessToken(token, "share123") {
t.Fatal("expected token to be invalid after single use")
}
}
func TestMintAndValidateShareUISessionToken(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
token, expiresAt, err := mintShareUISessionToken("share123", time.Hour)
if err != nil {
t.Fatalf("mintShareUISessionToken: %v", err)
}
if token == "" || expiresAt <= time.Now().Unix() {
t.Fatalf("unexpected token response: token=%q expiresAt=%d", token, expiresAt)
}
if !validateShareUISessionToken(token, "share123") {
t.Fatal("expected valid UI session token for matching hash")
}
if validateShareUISessionToken(token, "other-hash") {
t.Fatal("expected invalid UI session token for different hash")
}
}
func TestAuthorizeShareDownloadAccessTokenConcurrentSingleUse(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
token, _, mintErr := mintShareDownloadAccessToken("share123", time.Hour, 1)
if mintErr != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", mintErr)
}
allowed := make(chan bool, 2)
for i := 0; i < 2; i++ {
go func() {
allowed <- authorizeShareDownloadAccessToken(token, "share123")
}()
}
successes := 0
for i := 0; i < 2; i++ {
if <-allowed {
successes++
}
}
if successes != 1 {
t.Fatalf("expected exactly one concurrent authorization, got %d", successes)
}
}
func TestParseShareDownloadAccessDurationMax24Hours(t *testing.T) {
if _, err := parseShareDownloadAccessDuration(25, "hours"); err == nil {
t.Fatal("expected error for duration over 24 hours")
}
ttl, err := parseShareDownloadAccessDuration(2, "hours")
if err != nil {
t.Fatalf("parseShareDownloadAccessDuration: %v", err)
}
if ttl != 2*time.Hour {
t.Fatalf("ttl = %v, want 2h", ttl)
}
}
+12 -9
View File
@@ -9,40 +9,43 @@ import (
func TestApplySharePasswordUpdatePreservesWhenOmitted(t *testing.T) {
link := &share.Share{
PasswordHash: "hashed",
Token: "tok",
}
if err := applySharePasswordUpdate(link, nil, ""); err != nil {
if err := applySharePasswordUpdate(link, nil, "", ""); err != nil {
t.Fatal(err)
}
if link.PasswordHash != "hashed" {
t.Fatalf("expected preserved password hash, got hash=%q", link.PasswordHash)
if link.PasswordHash != "hashed" || link.Token != "tok" {
t.Fatalf("expected preserved secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
}
}
func TestApplySharePasswordUpdateClearsWhenEmpty(t *testing.T) {
link := &share.Share{
PasswordHash: "hashed",
Token: "tok",
}
empty := ""
if err := applySharePasswordUpdate(link, &empty, ""); err != nil {
if err := applySharePasswordUpdate(link, &empty, "", ""); err != nil {
t.Fatal(err)
}
if link.PasswordHash != "" {
t.Fatalf("expected cleared password hash, got hash=%q", link.PasswordHash)
if link.PasswordHash != "" || link.Token != "" {
t.Fatalf("expected cleared secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
}
}
func TestApplySharePasswordUpdateReplacesWhenProvided(t *testing.T) {
link := &share.Share{
PasswordHash: "old",
Token: "oldtok",
}
next := "new-password"
if err := applySharePasswordUpdate(link, &next, "newhash"); err != nil {
if err := applySharePasswordUpdate(link, &next, "newhash", "newtok"); err != nil {
t.Fatal(err)
}
if link.PasswordHash != "newhash" {
t.Fatalf("expected replaced password hash, got hash=%q", link.PasswordHash)
if link.PasswordHash != "newhash" || link.Token != "newtok" {
t.Fatalf("expected replaced secrets, got hash=%q token=%q", link.PasswordHash, link.Token)
}
}
-120
View File
@@ -1,120 +0,0 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestAuthenticateShareRequestUISessionCookie(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
link := share.Share{
ShareColumns: share.ShareColumns{Hash: "ui_session_share"},
PasswordHash: string(passwordHash),
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public/api/resources?hash=ui_session_share", nil)
req.Header.Set("X-SHARE-PASSWORD", "secret")
status, err := AuthenticateShareRequest(rec, req, link)
if err != nil || status != http.StatusOK {
t.Fatalf("expected password auth to succeed, status=%d err=%v", status, err)
}
cookies := rec.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != shareUISessionCookieName {
t.Fatalf("expected share session cookie, got %#v", cookies)
}
downloadReq := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash=ui_session_share", nil)
downloadReq.AddCookie(cookies[0])
status, err = AuthenticateShareRequest(nil, downloadReq, link)
if err != nil || status != http.StatusOK {
t.Fatalf("expected cookie auth to succeed, status=%d err=%v", status, err)
}
token, _, mintErr := mintShareUISessionToken("other_share", time.Hour)
if mintErr != nil {
t.Fatalf("mintShareUISessionToken: %v", mintErr)
}
wrongReq := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash=ui_session_share", nil)
wrongReq.AddCookie(&http.Cookie{Name: shareUISessionCookieName, Value: token})
status, err = AuthenticateShareRequest(nil, wrongReq, link)
if err != nil || status == http.StatusOK {
t.Fatalf("expected mismatched hash cookie to fail, status=%d err=%v", status, err)
}
}
func TestAuthenticateShareRequestDownloadTokenDoesNotMintUISession(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
link := share.Share{
ShareColumns: share.ShareColumns{Hash: "dl_token_share"},
PasswordHash: string(passwordHash),
}
downloadToken, _, err := mintShareDownloadAccessToken(link.Hash, time.Hour, 0)
if err != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash="+link.Hash+"&token="+downloadToken, nil)
req.Header.Set("X-SHARE-PASSWORD", "secret")
status, err := AuthenticateShareRequest(rec, req, link)
if err != nil || status != http.StatusOK {
t.Fatalf("expected download token auth to succeed, status=%d err=%v", status, err)
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("download token auth must not mint a UI session cookie when password header is also present")
}
}
func TestAuthenticateShareRequestDownloadTokenWrongPasswordNoUISession(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-auth-key"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
passwordHash, bcryptErr := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost)
if bcryptErr != nil {
t.Fatalf("bcrypt: %v", bcryptErr)
}
link := share.Share{
ShareColumns: share.ShareColumns{Hash: "dl_wrong_pw_share"},
PasswordHash: string(passwordHash),
}
downloadToken, _, mintErr := mintShareDownloadAccessToken(link.Hash, time.Hour, 0)
if mintErr != nil {
t.Fatalf("mintShareDownloadAccessToken: %v", mintErr)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public/api/resources/download?hash="+link.Hash+"&token="+downloadToken, nil)
req.Header.Set("X-SHARE-PASSWORD", "wrong-password")
status, authErr := AuthenticateShareRequest(rec, req, link)
if authErr != nil || status != http.StatusOK {
t.Fatalf("expected download token auth to succeed, status=%d err=%v", status, authErr)
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("valid download token with wrong password header must not mint a UI session cookie")
}
}
-3
View File
@@ -89,9 +89,6 @@ type Stats struct {
UsedAsIndexed uint64 `json:"used"`
UsedDisk uint64 `json:"usedAlt"`
DiskTotal uint64 `json:"total"`
// 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"`
}
// reduced index is json exposed to the client
+7 -3
View File
@@ -276,9 +276,9 @@ 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)
totalPartitionSize, err := fileutils.GetPartitionSize(sourcePath)
if err != nil {
logger.Errorf("Failed to get partition usage for index %s: %v", sourceName, err)
logger.Errorf("Failed to get partition size for index %s: %v", sourceName, err)
}
itemInfo, dbErr := idx.db.GetDirectoryChildren(idx.Name, "/")
if dbErr != nil {
@@ -298,6 +298,11 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
indexedSizeFromDB += uint64(item.Size)
}
}
// Also fetch OS-reported partition used space (total - free)
partitionUsed, err := fileutils.GetPartitionUsed(sourcePath)
if err != nil {
logger.Errorf("Failed to get partition used space for index %s: %v", sourceName, err)
}
idx.SetUsage(totalPartitionSize, partitionUsed, indexedSizeFromDB)
utils.DiskUsageCache.Set(cacheKey, true)
if metaStoreConfigured() {
@@ -333,7 +338,6 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
reducedIdx.UsedAsIndexed = idx.UsedAsIndexed
reducedIdx.UsedDisk = idx.UsedDisk
reducedIdx.DiskTotal = idx.DiskTotal
reducedIdx.UsageScopeMismatch = idx.DiskTotal > 0 && idx.UsedAsIndexed > idx.DiskTotal
reducedIdx.Scanners = scannerInfos
reducedIdx.NumDirs = idx.getNumDirsUnlocked()
reducedIdx.NumFiles = idx.getNumFilesUnlocked()
+5
View File
@@ -140,7 +140,12 @@ func ApplyUserDefaultsFrom(u *users.User, d UserDefaults) {
u.BackendScopes = MergeDefaultEnabledBackendScopes(u.BackendScopes)
if len(u.SidebarLinks) == 0 && len(u.BackendScopes) > 0 {
scope := u.BackendScopes[0]
name := scope.Path
if src := Config.Server.SourceMap[scope.Path]; src != nil && src.Name != "" {
name = src.Name
}
u.SidebarLinks = append(u.SidebarLinks, users.SidebarLink{
Name: name,
Category: "source",
Target: "/",
Icon: "",
+29 -35
View File
@@ -3088,7 +3088,7 @@ const docTemplate = `{
},
"/api/share/direct": {
"get": {
"description": "Mints a download-only ephemeral token for an existing password-protected share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes. Maximum token lifetime is 24 hours regardless of unit.",
"description": "Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.",
"consumes": [
"application/json"
],
@@ -3098,59 +3098,50 @@ const docTemplate = `{
"tags": [
"Shares"
],
"summary": "Get direct download link",
"summary": "Create direct download link",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"description": "File path to create download link for",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Duration value (default: 60)",
"description": "Source name for the file",
"name": "source",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Duration in minutes for link validity (default: 60)",
"name": "duration",
"in": "query"
},
{
"type": "string",
"description": "Duration unit: minutes or hours (default: minutes)",
"name": "unit",
"in": "query"
},
{
"type": "string",
"description": "Maximum number of downloads allowed with this token (default: unlimited within TTL)",
"description": "Maximum number of downloads allowed (default: unlimited)",
"name": "count",
"in": "query"
},
{
"type": "string",
"description": "Share password",
"name": "X-SHARE-PASSWORD",
"in": "header",
"required": true
"description": "Download speed limit in kbps (default: unlimited)",
"name": "speed",
"in": "query"
}
],
"responses": {
"200": {
"description": "Ephemeral download link",
"201": {
"description": "Direct download link created",
"schema": {
"$ref": "#/definitions/web.DirectDownloadResponse"
}
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Invalid or missing share password",
"description": "Bad request - invalid parameters or path is not a file",
"schema": {
"type": "object",
"additionalProperties": {
@@ -3159,7 +3150,7 @@ const docTemplate = `{
}
},
"403": {
"description": "Forbidden",
"description": "Forbidden - access denied",
"schema": {
"type": "object",
"additionalProperties": {
@@ -3167,8 +3158,8 @@ const docTemplate = `{
}
}
},
"404": {
"description": "Share not found",
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
@@ -6839,6 +6830,9 @@ const docTemplate = `{
"title": {
"type": "string"
},
"token": {
"type": "string"
},
"userDownloads": {
"type": "object",
"additionalProperties": {
@@ -8020,13 +8014,13 @@ const docTemplate = `{
"web.DirectDownloadResponse": {
"type": "object",
"properties": {
"expiresAt": {
"type": "integer"
},
"hash": {
"type": "string"
},
"token": {
"shareUrl": {
"type": "string"
},
"status": {
"type": "string"
},
"url": {
+29 -35
View File
@@ -3077,7 +3077,7 @@
},
"/api/share/direct": {
"get": {
"description": "Mints a download-only ephemeral token for an existing password-protected share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes. Maximum token lifetime is 24 hours regardless of unit.",
"description": "Creates a direct download link for a specific file with configurable duration, download count, and speed limits. If a share already exists with matching parameters, the existing share will be reused.",
"consumes": [
"application/json"
],
@@ -3087,59 +3087,50 @@
"tags": [
"Shares"
],
"summary": "Get direct download link",
"summary": "Create direct download link",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"description": "File path to create download link for",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Duration value (default: 60)",
"description": "Source name for the file",
"name": "source",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Duration in minutes for link validity (default: 60)",
"name": "duration",
"in": "query"
},
{
"type": "string",
"description": "Duration unit: minutes or hours (default: minutes)",
"name": "unit",
"in": "query"
},
{
"type": "string",
"description": "Maximum number of downloads allowed with this token (default: unlimited within TTL)",
"description": "Maximum number of downloads allowed (default: unlimited)",
"name": "count",
"in": "query"
},
{
"type": "string",
"description": "Share password",
"name": "X-SHARE-PASSWORD",
"in": "header",
"required": true
"description": "Download speed limit in kbps (default: unlimited)",
"name": "speed",
"in": "query"
}
],
"responses": {
"200": {
"description": "Ephemeral download link",
"201": {
"description": "Direct download link created",
"schema": {
"$ref": "#/definitions/web.DirectDownloadResponse"
}
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Invalid or missing share password",
"description": "Bad request - invalid parameters or path is not a file",
"schema": {
"type": "object",
"additionalProperties": {
@@ -3148,7 +3139,7 @@
}
},
"403": {
"description": "Forbidden",
"description": "Forbidden - access denied",
"schema": {
"type": "object",
"additionalProperties": {
@@ -3156,8 +3147,8 @@
}
}
},
"404": {
"description": "Share not found",
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
@@ -6828,6 +6819,9 @@
"title": {
"type": "string"
},
"token": {
"type": "string"
},
"userDownloads": {
"type": "object",
"additionalProperties": {
@@ -8009,13 +8003,13 @@
"web.DirectDownloadResponse": {
"type": "object",
"properties": {
"expiresAt": {
"type": "integer"
},
"hash": {
"type": "string"
},
"token": {
"shareUrl": {
"type": "string"
},
"status": {
"type": "string"
},
"url": {
+27 -33
View File
@@ -1280,6 +1280,8 @@ definitions:
type: string
title:
type: string
token:
type: string
userDownloads:
additionalProperties:
type: integer
@@ -2113,11 +2115,11 @@ definitions:
type: object
web.DirectDownloadResponse:
properties:
expiresAt:
type: integer
hash:
type: string
token:
shareUrl:
type: string
status:
type: string
url:
type: string
@@ -4441,66 +4443,58 @@ paths:
get:
consumes:
- application/json
description: Mints a download-only ephemeral token for an existing password-protected
share. Requires the share owner or an admin plus a valid X-SHARE-PASSWORD
header. API-only; not used by the FileBrowser UI. Default duration is 60 minutes.
Maximum token lifetime is 24 hours regardless of unit.
description: Creates a direct download link for a specific file with configurable
duration, download count, and speed limits. If a share already exists with
matching parameters, the existing share will be reused.
parameters:
- description: Share hash
- description: File path to create download link for
in: query
name: hash
name: path
required: true
type: string
- description: 'Duration value (default: 60)'
- description: Source name for the file
in: query
name: source
required: true
type: string
- description: 'Duration in minutes for link validity (default: 60)'
in: query
name: duration
type: string
- description: 'Duration unit: minutes or hours (default: minutes)'
in: query
name: unit
type: string
- description: 'Maximum number of downloads allowed with this token (default:
unlimited within TTL)'
- description: 'Maximum number of downloads allowed (default: unlimited)'
in: query
name: count
type: string
- description: Share password
in: header
name: X-SHARE-PASSWORD
required: true
- description: 'Download speed limit in kbps (default: unlimited)'
in: query
name: speed
type: string
produces:
- application/json
responses:
"200":
description: Ephemeral download link
"201":
description: Direct download link created
schema:
$ref: '#/definitions/web.DirectDownloadResponse'
"400":
description: Bad request
schema:
additionalProperties:
type: string
type: object
"401":
description: Invalid or missing share password
description: Bad request - invalid parameters or path is not a file
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden
description: Forbidden - access denied
schema:
additionalProperties:
type: string
type: object
"404":
description: Share not found
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Get direct download link
summary: Create direct download link
tags:
- Shares
/api/share/list:
+361 -433
View File
File diff suppressed because it is too large. Load diff
+7 -3
View File
@@ -19,10 +19,11 @@ export async function getSubtitleContent(source, path, subtitleName, embedded =
const apiPath = getPublicApiPath('media/subtitles', {
hash,
...baseParams,
...(state.shareInfo.token && { token: state.shareInfo.token }),
})
const sharePassword = localStorage.getItem(`sharepass:${hash}`) || ''
res = await fetchURL(apiPath, {
headers: { 'X-SHARE-PASSWORD': encodeURIComponent(sharePassword) },
headers: { 'X-SHARE-PASSWORD': sharePassword },
})
} else {
const apiPath = getApiPath('media/subtitles', {
@@ -56,10 +57,11 @@ export async function getLyricsPublic(path, hash, password = "") {
const params = {
path,
hash,
...(state.shareInfo.token && { token: state.shareInfo.token }),
};
const apiPath = getPublicApiPath("media/lyrics", params);
const response = await fetch(apiPath, {
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password || "") },
headers: { "X-SHARE-PASSWORD": password || "" },
});
if (!response.ok) {
const error = new Error(response.statusText);
@@ -96,10 +98,11 @@ export async function fetchDirectoryMediaMetadataPublic(path, hash, password = "
path,
hash,
...(albumArt ? { albumArt: "true" } : {}),
...(state.shareInfo.token && { token: state.shareInfo.token }),
};
const apiPath = getPublicApiPath("media/metadata", params);
const response = await fetch(apiPath, {
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password || "") },
headers: { "X-SHARE-PASSWORD": password || "" },
});
if (!response.ok) {
const error = new Error(response.statusText);
@@ -176,6 +179,7 @@ export function getStreamURLPublic(share, files, viewToken) {
const params = {
file: fileArray,
hash: share.hash,
token: share.token,
viewToken: viewToken,
sessionId: state.sessionId,
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { notify } from '@/notify'
import { state } from '@/store'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { fetchURL } from './utils'
@@ -9,13 +10,14 @@ export async function getConfig(req) {
path: req.path,
...(req.hash && { hash: req.hash }),
...(req.source && { source: req.source }),
...(req.hash && state.shareInfo?.token && { token: state.shareInfo.token }),
}
const headers = {}
if (req.hash) {
const sharePassword = localStorage.getItem(`sharepass:${req.hash}`)
if (sharePassword) {
headers['X-SHARE-PASSWORD'] = encodeURIComponent(sharePassword)
headers['X-SHARE-PASSWORD'] = sharePassword
}
}
+36 -23
View File
@@ -99,6 +99,7 @@ export async function signalUploadPause(source, path, shareHash) {
const apiPath = getPublicApiPath('resources/pause', {
hash: shareHash,
path: path,
...sharePublicAuthQuery(shareHash),
})
await fetchURL(apiPath, { method: 'POST', headers: sharePublicAuthHeaders(shareHash) })
return
@@ -241,6 +242,7 @@ export async function download(format, files, shareHash = "") {
algo: format,
...(shareHash && { hash: shareHash }),
...(!shareHash && source && { source: source }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId,
}
@@ -262,17 +264,6 @@ export async function download(format, files, shareHash = "") {
}, 100)
}
function shareDownloadFetchInit(method = "GET", extraHeaders = {}) {
return {
method,
credentials: "same-origin",
headers: {
...sharePublicAuthHeaders(state.shareInfo?.hash || ""),
...extraHeaders,
},
};
}
/** Best-effort text from a failed download response (consumes body). */
async function readDownloadErrorBody(response) {
try {
@@ -304,7 +295,7 @@ function appendArchiveTokenToUrl(url, token) {
/** @returns {Promise<{ size: number, token: string | null }>} */
async function resolveDownloadContentLength(url) {
let token = null
let res = await fetch(url, shareDownloadFetchInit('HEAD'))
let res = await fetch(url, { method: 'HEAD', credentials: 'same-origin' })
if (res.ok) {
token = res.headers.get('X-Archive-Token')
const cl = res.headers.get('Content-Length')
@@ -315,7 +306,11 @@ async function resolveDownloadContentLength(url) {
}
}
}
res = await fetch(url, shareDownloadFetchInit('GET', { Range: 'bytes=0-0' }))
res = await fetch(url, {
method: 'GET',
headers: { Range: 'bytes=0-0' },
credentials: 'same-origin',
})
const cancelBody = () => {
if (res.body && typeof res.body.cancel === 'function') {
res.body.cancel().catch(() => {})
@@ -400,7 +395,10 @@ async function chunkedRangeDownloadToBlob(
const rangeHeader = `bytes=${offset}-${end}`
const response = await fetch(baseUrl, {
...shareDownloadFetchInit('GET', { Range: rangeHeader }),
headers: {
Range: rangeHeader,
},
credentials: 'same-origin',
signal: abortController.signal,
})
@@ -612,6 +610,7 @@ async function downloadChunked(file, shareHash = "") {
file: file.path,
...(shareHash && { hash: shareHash }),
...(!shareHash && file.source && { source: file.source }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
@@ -984,6 +983,7 @@ export function getRawViewURLPublic(share, files, viewToken) {
const params = {
file: fileArray,
hash: share.hash,
token: share.token,
viewToken: viewToken,
sessionId: state.sessionId,
}
@@ -1126,12 +1126,19 @@ function getSharePasswordFromStorage(hash) {
return localStorage.getItem(`sharepass:${hash}`) || "";
}
function sharePublicAuthQuery(hash) {
if (state.shareInfo?.hash === hash && state.shareInfo.token) {
return { token: state.shareInfo.token };
}
return {};
}
function sharePublicAuthHeaders(hash) {
const password = getSharePasswordFromStorage(hash);
if (!password) {
return {};
}
return { "X-SHARE-PASSWORD": encodeURIComponent(password) };
return { "X-SHARE-PASSWORD": password };
}
// Fetch public share data
@@ -1150,12 +1157,13 @@ export async function fetchFilesPublic(path, hash, password = "", content = fals
...(skipExtendedAttrs && { skipExtendedAttrs: 'true' }),
...(content && { content: 'true' }),
...(metadata && { metadata: 'true' }),
...(state.shareInfo.token && { token: state.shareInfo.token })
}
const apiPath = getPublicApiPath("resources", params);
const sharePassword = password || getSharePasswordFromStorage(hash);
const response = await fetch(apiPath, {
credentials: "same-origin",
headers: sharePassword ? { "X-SHARE-PASSWORD": encodeURIComponent(sharePassword) } : {},
headers: {
"X-SHARE-PASSWORD": password || "",
},
});
if (!response.ok) {
@@ -1187,6 +1195,7 @@ export async function getItemsPublic(hash, path, only = "") {
path: path,
hash: hash,
...(only && { only: only }),
...sharePublicAuthQuery(hash),
})
const response = await fetch(apiPath, { headers: sharePublicAuthHeaders(hash) })
const data = await response.json()
@@ -1199,15 +1208,16 @@ export async function getItemsPublic(hash, path, only = "") {
// Generate a download URL
/**
* @param {{ path: string; hash: string; inline?: boolean }} share
* @param {{ path: string; hash: string; token: string; inline?: boolean }} share
* @param {string[]} files - Array of file paths (will be converted to repeated 'file' parameters)
* @returns {string}
*/
export function getDownloadURLPublic(share, files, inline=false) {
const fileArray = Array.isArray(files) ? files : [files]
const params = {
file: fileArray,
file: fileArray, // Array will be converted to repeated 'file' params by getPublicApiPath
hash: share.hash,
token: share.token,
...(inline && { inline: 'true' })
}
const apiPath = getPublicApiPath("resources/download", params)
@@ -1220,14 +1230,14 @@ export function getDownloadURLPublic(share, files, inline=false) {
* @param {string} size - The size parameter (small, large, original). Omit for default (small).
* @returns {string}
*/
export function getPreviewURLPublic(path, size, viewToken = state.req?.viewToken) {
export function getPreviewURLPublic(path, size) {
try {
const params = {
path: path,
hash: state.shareInfo.hash,
inline: 'true',
...(size && size !== 'small' && { size: size }),
...(viewToken && { viewToken }),
...(state.shareInfo.token && { token: state.shareInfo.token })
}
const apiPath = getPublicApiPath('resources/preview', params)
return window.origin + apiPath
@@ -1256,6 +1266,7 @@ export function postPublic(
hash: hash,
override: overwrite,
...(isDir && { isDir: 'true' }),
...sharePublicAuthQuery(hash),
});
const request = new XMLHttpRequest();
@@ -1345,6 +1356,7 @@ async function resourceActionPublic(hash, path, method, content, token = "") {
path,
hash: hash,
...(token && { token }),
...sharePublicAuthQuery(hash),
})
const response = await fetch(apiPath, {
method,
@@ -1388,6 +1400,7 @@ export async function bulkDeletePublic(items) {
const params = {
hash: hash,
...sharePublicAuthQuery(hash),
sessionId: state.sessionId
}
const apiPath = getPublicApiPath("resources/bulk", params)
@@ -1458,7 +1471,7 @@ export async function moveCopyPublic(
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...sharePublicAuthHeaders(hash),
...(state.shareInfo.token && { 'X-Auth-Token': state.shareInfo.token })
},
body: JSON.stringify(requestBody),
})
+1
View File
@@ -901,6 +901,7 @@ export default {
? resourcesApi.getOpenFileURL(source, path, {
path: state.shareInfo.subPath,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
})
: resourcesApi.getOpenFileURL(source, path);
window.open(openUrl, "_blank");
+2 -4
View File
@@ -27,7 +27,6 @@ https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/compon
<script>
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { cappedUsagePercent } from "@/utils/progressDisplay.js";
// We're leaving this untouched as you can read in the beginning
const isNumber = (n) => {
@@ -135,9 +134,8 @@ export default {
if (!isNumber(displayVal)) return displayVal;
// Cap % like the bar fill so nested-mount indexed size > partition total
// does not show absurd values (e.g. 1294%); keep byte sizes honest.
const percentage = cappedUsagePercent(displayVal, this.max);
const percentage =
this.max > 0 ? Math.round((displayVal / this.max) * 100) : 0;
if (this.unit === "bytes" && isNumber(displayVal)) {
const valFormatted = getHumanReadableFilesize(displayVal);
-18
View File
@@ -39,8 +39,6 @@ import { getObjectProperty } from '@/utils/object.js';
import { mutations, state, getters } from "@/store";
import { setImageLoaded } from "@/utils/imageCache";
const POPUP_PREVIEW_HOVER_DELAY_MS = 200;
export default {
name: "Icon",
components: {
@@ -105,7 +103,6 @@ export default {
classes: "",
svgPath: "",
previewTimeouts: [],
popupHoverTimeoutId: null,
previewAbortController: null,
preloadAbortControllers: [],
imageBlobUrl: null,
@@ -269,14 +266,7 @@ export default {
// When 3D preview fails, fall back to material icon
this.threeJsError = true;
},
clearPopupHoverTimeout() {
if (this.popupHoverTimeoutId !== null) {
clearTimeout(this.popupHoverTimeoutId);
this.popupHoverTimeoutId = null;
}
},
cancelPreloadRequests() {
this.clearPopupHoverTimeout();
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
for (const controller of this.preloadAbortControllers) {
@@ -346,13 +336,6 @@ export default {
if (!getters.previewPerms().popup || !this.path) {
return;
}
this.clearPopupHoverTimeout();
this.popupHoverTimeoutId = setTimeout(() => {
this.popupHoverTimeoutId = null;
this.showPopupPreview();
}, POPUP_PREVIEW_HOVER_DELAY_MS);
},
showPopupPreview() {
const source = getters.isShare() ? state.shareInfo?.hash : (this.source || state.req.source);
const modified = this.modified || state.req.modified;
@@ -433,7 +416,6 @@ export default {
updateThumbnail();
},
handleMouseLeave() {
this.clearPopupHoverTimeout();
this.cancelPreloadRequests();
mutations.setPreviewSource("");
// Clear popup preview source info when mouse leaves
+1 -10
View File
@@ -3,7 +3,7 @@
<div v-if="showWrongCredentials" class="form-invalid">
{{ $t("login.wrongCredentials") }}
</div>
<div v-if="infoText" class="password-info">
<div v-if="infoText">
<p>{{ infoText }}</p>
</div>
<div class="form-flex-group">
@@ -85,15 +85,6 @@ export default {
</script>
<style scoped>
.password-info {
margin-bottom: 1em;
}
.password-info p {
margin: 0;
color: var(--textPrimary);
}
.wrong__password {
color: #ff4757;
text-align: center;
@@ -6,7 +6,6 @@
<button
type="button"
class="button button--flat button--blue"
:id="canReplace ? undefined : 'focus-prompt'"
@click="(event) => currentPrompt.confirm(event, 'rename')"
:aria-label="$t('general.rename')"
:title="$t('general.rename')"
@@ -14,7 +13,6 @@
{{ $t("general.rename") }}
</button>
<button
v-if="canReplace"
type="button"
id="focus-prompt"
class="button button--flat button--red"
@@ -28,24 +26,18 @@
</template>
<script>
import { getters, state } from "@/store";
import { getters } from "@/store"; // Import your custom store
export default {
name: "replace-rename",
computed: {
currentPrompt() {
return getters.currentPrompt();
return getters.currentPrompt(); // Access the getter directly from the store
},
isSameFile() {
// Check if the current prompt has props indicating same file
return this.currentPrompt.props?.isSameFile === true;
},
canReplace() {
const canModify = !!getters.sourcePermissions().modify;
if (getters.isShare()) {
return canModify && !!state.shareInfo?.allowReplacements;
}
return canModify;
},
},
methods: {},
};
+6 -6
View File
@@ -190,7 +190,7 @@ import { notify } from "@/notify";
import { validateLogin } from "@/utils/auth";
import { globalVars } from "@/utils/constants";
import { eventBus } from "@/store/eventBus";
import { getObjectProperty, setObjectProperty } from '@/utils/object.js';
import { setObjectProperty } from '@/utils/object.js';
import {
sectionsFromFlatUser,
applySectionsToFlatUser,
@@ -751,9 +751,9 @@ export default {
.sort((a, b) => a.name.localeCompare(b.name));
},
buildProfileSnapshot(user) {
let snapshot = {};
const snapshot = {};
for (const field of PROFILE_SNAPSHOT_FIELDS) {
snapshot = setObjectProperty(snapshot, field, getObjectProperty(user, field));
snapshot[field] = user?.[field];
}
if (snapshot.preview && typeof snapshot.preview === "object") {
snapshot.preview = { ...snapshot.preview };
@@ -814,8 +814,8 @@ export default {
const permissionFields = ["admin", "share", "api", "realtime"];
for (const perm of permissionFields) {
if (
getObjectProperty(current.permissions, perm) !== getObjectProperty(orig.permissions, perm)
&& !getObjectProperty(this.enforcedAccountPermissions, perm)
current.permissions[perm] !== orig.permissions[perm]
&& !this.enforcedAccountPermissions[perm]
) {
fields.push("permissions");
break;
@@ -826,7 +826,7 @@ export default {
if (isFlatProfileFieldEnforced(this.enforcedPreferences, field)) {
continue;
}
if (JSON.stringify(getObjectProperty(current.profile, field)) !== JSON.stringify(getObjectProperty(orig.profile, field))) {
if (JSON.stringify(current.profile?.[field]) !== JSON.stringify(orig.profile?.[field])) {
fields.push(field);
}
}
@@ -90,7 +90,6 @@
<script>
import SettingsItem from "@/components/settings/SettingsItem.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import { getObjectProperty } from "@/utils/object.js";
export default {
name: "UserDefaultsAccountSection",
@@ -140,22 +139,22 @@ export default {
if (this.isFieldLocked(field)) {
return true;
}
return this.respectEnforcedPolicy && !!getObjectProperty(this.enforced, field);
return this.respectEnforcedPolicy && !!this.enforced[field];
},
isPermissionDisabled(field) {
if (this.isPermissionLocked(field)) {
return true;
}
return this.respectEnforcedPolicy && !!getObjectProperty(this.enforcedPermissions, field);
return this.respectEnforcedPolicy && !!this.enforcedPermissions[field];
},
fieldDisabledTooltip(field) {
if (this.respectEnforcedPolicy && getObjectProperty(this.enforced, field)) {
if (this.respectEnforcedPolicy && this.enforced[field]) {
return this.$t("profileSettings.enforcedByAdmin");
}
return this.configLockTooltipForField(field);
},
permissionDisabledTooltip(field) {
if (this.respectEnforcedPolicy && getObjectProperty(this.enforcedPermissions, field)) {
if (this.respectEnforcedPolicy && this.enforcedPermissions[field]) {
return this.$t("profileSettings.enforcedByAdmin");
}
return this.configLockTooltipForPermission(field);
+33 -29
View File
@@ -90,7 +90,7 @@
: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"
:status="getProgressBarStatus(link, sourceInfo[link.sourceName] || {})"
:status="getProgressBarStatus(sourceInfo[link.sourceName] || {})"
unit="bytes">
</ProgressBar>
<!-- For other source types, show single bar -->
@@ -99,7 +99,7 @@
: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"
:status="getProgressBarStatus(link, sourceInfo[link.sourceName] || {})"
:status="getProgressBarStatus(sourceInfo[link.sourceName] || {})"
unit="bytes">
</ProgressBar>
</div>
@@ -170,14 +170,14 @@
:val-background="(activeSourceInfo).usedAlt || 0"
:val-text="activeSourceLink.category === 'source-hybrid-2' ? ((activeSourceInfo).usedAlt || 0) : null"
:max="(activeSourceInfo).total || 1"
:status="getProgressBarStatus(activeSourceLink, activeSourceInfo)"
:status="getProgressBarStatus(activeSourceInfo)"
unit="bytes">
</ProgressBar>
<ProgressBar
v-else
:val="getProgressBarValue(activeSourceLink, activeSourceInfo)"
:max="(activeSourceInfo).total || 1"
:status="getProgressBarStatus(activeSourceLink, activeSourceInfo)"
:status="getProgressBarStatus(activeSourceInfo)"
unit="bytes">
</ProgressBar>
</div>
@@ -218,7 +218,7 @@ import { getIconClass } from "@/utils/material-symbols";
import { getObjectProperty } from '@/utils/object.js';
import IndexInfo from "@/components/files/IndexInfo.vue";
import { globalVars } from "@/utils/constants";
import { showShareDownloadPrompt } from "@/utils/download.js";
import { resourcesApi } from "@/api";
import ShareInfo from "@/components/files/ShareInfo.vue";
import FileTree from '@/components/files/FileTree.vue';
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
@@ -459,28 +459,12 @@ export default {
// Direct access to reactive computed property ensures Vue tracks changes
return this.sourceInfo && link.sourceName ? this.sourceInfo[link.sourceName] || {} : {};
},
getProgressBarStatus(link, sourceInfo) {
getProgressBarStatus(sourceInfo) {
if (sourceInfo.status === 'indexing' && sourceInfo.complexity === 0) {
return 'indexing';
}
if (this.hasUsageScopeMismatch(link, sourceInfo)) {
return 'conflict';
}
return 'default';
},
hasUsageScopeMismatch(link, sourceInfo) {
if (!link || !sourceInfo) return false;
const cat = link.category;
if (cat !== 'source' && cat !== 'source-hybrid' && cat !== 'source-hybrid-2') {
return false;
}
if (sourceInfo.usageScopeMismatch) {
return true;
}
const used = Number(sourceInfo.used) || 0;
const total = Number(sourceInfo.total) || 0;
return total > 0 && used > total;
},
getProgressBarValue(link, sourceInfo) {
// Called with (link, sourceInfo) from both modes
if (link.category === 'source-alt') {
@@ -529,13 +513,33 @@ export default {
mutations.closeTopPrompt();
},
goToDownload() {
showShareDownloadPrompt([{
path: state.req?.path || "/",
name: state.req?.name,
type: state.req?.type,
size: state.req?.size,
isDir: state.req?.type === "directory",
}]);
// Check if we're in a directory with multiple items
const hasMultipleItems = state.req.items && state.req.items.length > 1;
if (hasMultipleItems) {
// Show format selector for directories with multiple items
mutations.showPrompt({
name: "download",
confirm: (format) => {
mutations.closeTopPrompt();
const downloadLink = resourcesApi.getDownloadURLPublic({
path: "/",
hash: state.shareInfo.hash,
token: state.shareInfo.token,
inline: false,
}, [state.req.path]);
window.open(`${downloadLink}&format=${format}`, "_blank");
},
});
} else {
// Direct download for single files or directories
const downloadLink = resourcesApi.getDownloadURLPublic({
path: "/",
hash: state.shareInfo.hash,
token: state.shareInfo.token,
inline: false,
}, [state.req.path]);
window.open(downloadLink, "_blank");
}
},
navigateTo(path, hash) {
mutations.setPreviousHistoryItem({
+2 -2
View File
@@ -71,8 +71,8 @@ i.spin {
-ms-overflow-style: none; /* IE and Edge */
position: fixed;
padding-top: var(--header-height);
padding-left: var(--safe-area-left);
padding-right: var(--safe-area-right);
padding-left: calc(0.5em + var(--safe-area-left));
padding-right: calc(0.5em + var(--safe-area-right));
overflow: unset;
top: 0;
/* fill the visible viewport only; the html background paints the black-translucent safe areas */
+5 -4
View File
@@ -147,9 +147,7 @@ export const mutations = {
used,
usedAlt: source.usedAlt || 0,
total,
usedPercentage: total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0,
usageScopeMismatch:
source.usageScopeMismatch === true || (total > 0 && used > total),
usedPercentage: total > 0 ? Math.round((used / total) * 100) : 0,
status: source.status || "unknown",
name: source.name || k,
files: source.numFiles || 0,
@@ -214,7 +212,6 @@ export const mutations = {
total: merge ? prev.total : 0,
usedAlt: merge ? prev.usedAlt : 0,
usedPercentage: merge ? prev.usedPercentage : 0,
usageScopeMismatch: merge ? prev.usageScopeMismatch : false,
status: merge ? prev.status : "unknown",
name: merge ? prev.name : source.name,
files: merge ? prev.files : 0,
@@ -1017,6 +1014,7 @@ export const mutations = {
{
path: item.path,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
},
false,
typeHint,
@@ -1188,6 +1186,9 @@ export const mutations = {
},
setShareInfo: (shareInfo) => {
const merged = { ...state.shareInfo, ...shareInfo };
if (shareInfo.token === undefined && state.shareInfo.token) {
merged.token = state.shareInfo.token;
}
if (shareInfo.passwordValid === undefined && state.shareInfo.passwordValid !== undefined) {
merged.passwordValid = state.shareInfo.passwordValid;
}
-3
View File
@@ -62,7 +62,6 @@ export interface ShareInfoObject {
disableFileViewer?: boolean;
allowModify?: boolean;
allowCreate?: boolean;
allowReplacements?: boolean;
allowDelete?: boolean;
disableDownload?: boolean;
showHidden?: boolean;
@@ -112,7 +111,6 @@ export interface SourceInfo {
total: number;
usedAlt: number;
usedPercentage: number;
usageScopeMismatch?: boolean;
status: string;
name: string;
files: number;
@@ -131,7 +129,6 @@ export interface SourceInfoUpdate {
used?: number;
total?: number;
usedAlt?: number;
usageScopeMismatch?: boolean;
status?: string;
name?: string;
numFiles?: number;
-27
View File
@@ -93,30 +93,3 @@ async function startDownload(config, files, hash = "", options = {}) {
notify.showError(`Error downloading: ${e.message || e}`);
}
}
/** Show the download format prompt and start the in-app download when confirmed. */
export function showShareDownloadPrompt(items) {
if (items.length === 0) {
notify.showError("No files selected");
return;
}
if (typeof items[0] === "number") {
items = items.map((i) => state.req.items.at(i));
}
const downloadChunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0;
const isMultiItemArchive =
items.length > 1 || (items.length === 1 && items[0].isDir);
const willUseChunkedArchive =
downloadChunkSizeMb > 0 && isMultiItemArchive;
mutations.showPrompt({
name: "download",
confirm: (format) => {
mutations.closeTopPrompt();
void startDownload(format, items, state.shareInfo?.hash || "", {
silentChunkedError: willUseChunkedArchive,
});
},
});
}
+1
View File
@@ -119,6 +119,7 @@ export function buildPreviewResourceUrl(
{
path: state.shareInfo.subPath,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
},
false,
resolvedPath,
-12
View File
@@ -1,12 +0,0 @@
/**
* Percentage for usage labels, capped at 100 to match ProgressBar fill width.
* Byte sizes in the label stay uncapped so nested-mount mismatches remain visible.
*/
export function cappedUsagePercent(val, max) {
const n = Number(val);
const m = Number(max);
if (!Number.isFinite(n) || !Number.isFinite(m) || m <= 0) {
return 0;
}
return Math.min(100, Math.round((n / m) * 100));
}
@@ -1,21 +0,0 @@
import { describe, it, expect } from "vitest";
import { cappedUsagePercent } from "./progressDisplay.js";
describe("cappedUsagePercent", () => {
it("returns normal percentages under capacity", () => {
expect(cappedUsagePercent(50, 100)).toBe(50);
expect(cappedUsagePercent(0, 100)).toBe(0);
expect(cappedUsagePercent(100, 100)).toBe(100);
});
it("caps above 100% when used exceeds total (nested mounts)", () => {
// 1.3 TB / 106.5 GB ≈ 1294% without capping
expect(cappedUsagePercent(1.3e12, 106.5e9)).toBe(100);
expect(cappedUsagePercent(200, 100)).toBe(100);
});
it("returns 0 for invalid max", () => {
expect(cappedUsagePercent(50, 0)).toBe(0);
expect(cappedUsagePercent(50, -1)).toBe(0);
});
});
+8 -27
View File
@@ -83,8 +83,7 @@ class UploadManager {
this.pendingItems = null; // Store pending items during conflict resolution
this.probedDirs = new Set(); // Track directories that were probed/created during conflict check
this.progressTimeouts = new Map(); // Track progress timeouts per upload ID
this.lastUploadActivityTime = null;
this.PROGRESS_TIMEOUT_MS = 10000; // 10 seconds without upload activity = pause
this.PROGRESS_TIMEOUT_MS = 10000; // 10 seconds without progress = pause
}
setOnConflict(handler) {
@@ -390,7 +389,6 @@ class UploadManager {
upload.xhr = promise.xhr;
await promise;
this.recordUploadActivity(upload);
// Clear timeout on successful completion
this.clearProgressTimeout(upload.id);
upload.status = "completed";
@@ -468,8 +466,8 @@ class UploadManager {
// Only increment chunkOffset after successful chunk upload.
// This ensures that if we pause/error mid-chunk, we'll retry that chunk on resume.
upload.chunkOffset += chunk.size;
// A completed chunk is activity even when no progress event was emitted.
this.recordUploadActivity(upload);
// Update last progress time after successful chunk upload
upload.lastProgressTime = Date.now();
} catch (err) {
this.clearProgressTimeout(upload.id);
await this.handleUploadError(upload, err);
@@ -561,44 +559,27 @@ class UploadManager {
}
}
recordUploadActivity(upload) {
upload.lastProgressTime = Date.now();
this.lastUploadActivityTime = upload.lastProgressTime;
}
startProgressTimeout(upload, delay = this.PROGRESS_TIMEOUT_MS) {
startProgressTimeout(upload) {
// Clear any existing timeout
this.clearProgressTimeout(upload.id);
// Requests can wait for bandwidth or a browser connection while siblings upload.
// Set new timeout to pause if no progress for 10 seconds
const timeoutId = setTimeout(() => {
this.progressTimeouts.delete(upload.id);
if (upload.status === "uploading") {
const lastActivity = Math.max(
upload.lastProgressTime ?? 0,
this.lastUploadActivityTime ?? 0
);
const idleTime = Date.now() - lastActivity;
if (idleTime < this.PROGRESS_TIMEOUT_MS) {
this.startProgressTimeout(upload, this.PROGRESS_TIMEOUT_MS - idleTime);
return;
}
console.log(`Upload ${upload.id} stalled - no progress for ${this.PROGRESS_TIMEOUT_MS}ms, pausing`);
upload.connectionIssue = true;
void this.pause(upload.id);
upload.errorDetails = "Connection stalled - upload paused. Click resume to retry.";
}
}, delay);
this.progressTimeouts.delete(upload.id);
}, this.PROGRESS_TIMEOUT_MS);
this.progressTimeouts.set(upload.id, timeoutId);
}
updateProgress(upload, progress) {
if (upload.status !== "uploading" || !this.findById(upload.id)) {
return;
}
upload.progress = progress;
this.recordUploadActivity(upload);
upload.lastProgressTime = Date.now();
upload.connectionIssue = false; // Clear connection issue on successful progress
// Reset the timeout whenever we get progress
this.startProgressTimeout(upload);
-173
View File
@@ -1,173 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/api", () => ({
resourcesApi: {
post: vi.fn(),
postPublic: vi.fn(),
signalUploadPause: vi.fn(),
},
}));
vi.mock("@/store", () => ({
state: {
user: { fileLoading: { maxConcurrentUpload: 10, uploadChunkSizeMb: 5 } },
shareInfo: { hash: "share" },
},
mutations: { setIsUploading: vi.fn(), setReload: vi.fn() },
}));
vi.mock("@/store/getters", () => ({ getters: { isShare: vi.fn(() => false) } }));
vi.mock("@/utils/appNotifications", () => ({
notifyUploadComplete: vi.fn(),
notifyUploadError: vi.fn(),
}));
import { resourcesApi } from "@/api";
import { getters } from "@/store/getters";
import { uploadManager } from "./upload";
function addUpload(status = "uploading", file = new Blob(["a"])) {
uploadManager.queue.push({
id: uploadManager.nextId++,
type: "file",
source: "test",
path: "/file",
file,
size: file.size,
status,
progress: 0,
chunkOffset: 0,
lastProgressTime: Date.now(),
connectionIssue: false,
});
return uploadManager.queue.at(-1);
}
function deferred() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(0);
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
getters.isShare.mockReturnValue(false);
uploadManager.queue.splice(0);
uploadManager.activeUploads = 0;
uploadManager.nextId = 0;
uploadManager.isOverallPaused = false;
uploadManager.hadActiveUploads = false;
uploadManager.lastUploadActivityTime = null;
});
afterEach(() => {
for (const id of uploadManager.progressTimeouts.keys()) {
uploadManager.clearProgressTimeout(id);
}
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("upload stall detection", () => {
it("allows an upload to wait while another progresses, then detects a batch stall", async () => {
const waiting = addUpload();
const progressing = addUpload();
uploadManager.startProgressTimeout(waiting);
uploadManager.startProgressTimeout(progressing);
for (let i = 0; i < 7; i++) {
await vi.advanceTimersByTimeAsync(5000);
// The API rounds progress, so successive events can have the same percent.
uploadManager.updateProgress(progressing, 1);
expect(waiting.status).toBe("uploading");
}
await vi.advanceTimersByTimeAsync(9999);
expect(waiting.status).toBe("uploading");
await vi.advanceTimersByTimeAsync(1);
expect(waiting.status).toBe("paused");
expect(progressing.status).toBe("paused");
expect(waiting.connectionIssue).toBe(true);
expect(uploadManager.progressTimeouts.size).toBe(0);
});
it("still pauses a single upload after ten seconds without activity", async () => {
const upload = addUpload();
uploadManager.startProgressTimeout(upload);
await vi.advanceTimersByTimeAsync(9999);
expect(upload.status).toBe("uploading");
await vi.advanceTimersByTimeAsync(1);
expect(upload.status).toBe("paused");
});
it.each([false, true])("keeps completion activity after a sibling finishes (share=%s)", async (share) => {
getters.isShare.mockReturnValue(share);
const request = deferred();
const post = share ? resourcesApi.postPublic : resourcesApi.post;
post.mockReturnValueOnce(request.promise);
const waiting = addUpload();
const finishing = addUpload("pending");
uploadManager.activeUploads = 1;
uploadManager.startProgressTimeout(waiting);
const done = uploadManager.startFileUpload(finishing);
await vi.advanceTimersByTimeAsync(9900);
request.resolve("");
await done;
expect(finishing.status).toBe("completed");
await vi.advanceTimersByTimeAsync(9999);
expect(waiting.status).toBe("uploading");
await vi.advanceTimersByTimeAsync(1);
expect(waiting.status).toBe("paused");
});
it.each([false, true])("counts a successful chunk even without progress events (share=%s)", async (share) => {
getters.isShare.mockReturnValue(share);
const first = deferred();
const second = deferred();
const post = share ? resourcesApi.postPublic : resourcesApi.post;
post.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const waiting = addUpload();
const chunked = addUpload("pending", new Blob([new Uint8Array(5 * 1024 * 1024 + 1)]));
uploadManager.activeUploads = 1;
uploadManager.startProgressTimeout(waiting);
const done = uploadManager.startFileUpload(chunked);
await vi.advanceTimersByTimeAsync(9900);
first.resolve("");
await vi.advanceTimersByTimeAsync(100);
expect(chunked.chunkOffset).toBe(5 * 1024 * 1024);
expect(waiting.status).toBe("uploading");
second.resolve("");
await done;
});
it.each(["pause", "cancel"])("ignores late progress after %s", async (action) => {
const upload = addUpload();
uploadManager.startProgressTimeout(upload);
if (action === "pause") {
await uploadManager.pause(upload.id);
} else {
uploadManager.cancel(upload.id);
}
uploadManager.updateProgress(upload, 50);
expect(uploadManager.progressTimeouts.size).toBe(0);
});
it("gives a resumed upload a fresh inactivity window", async () => {
const upload = addUpload();
uploadManager.startProgressTimeout(upload);
await uploadManager.pause(upload.id);
await vi.advanceTimersByTimeAsync(20000);
const request = deferred();
resourcesApi.post.mockReturnValueOnce(request.promise);
uploadManager.resume(upload.id);
await vi.advanceTimersByTimeAsync(9999);
expect(upload.status).toBe("uploading");
request.resolve("");
await vi.advanceTimersByTimeAsync(0);
expect(upload.status).toBe("completed");
expect(uploadManager.progressTimeouts.size).toBe(0);
});
});
+7 -5
View File
@@ -2,8 +2,6 @@
* Maps flat runtime user objects ↔ nested user-defaults / profile sections (matches backend ProfileFromUser).
*/
import { getNestedProperty, getObjectProperty } from "@/utils/object.js";
function boolPtr(val, defaultValue = true) {
if (val === undefined || val === null) {
return defaultValue;
@@ -212,7 +210,11 @@ function enforcedFlagAt(enforced, section, field) {
if (!enforced || !section) {
return false;
}
return !!getNestedProperty(enforced, section, field);
const sectionData = enforced[section];
if (!sectionData || typeof sectionData !== "object") {
return false;
}
return !!sectionData[field];
}
/** True when global user-default enforcement locks a flat profile PATCH field. */
@@ -223,8 +225,8 @@ export function isFlatProfileFieldEnforced(enforced, flatField) {
if (flatField === "preview" || flatField === "fileLoading") {
return false;
}
const path = getObjectProperty(FLAT_PROFILE_FIELD_ENFORCED_PATHS, flatField);
if (!Array.isArray(path)) {
const path = FLAT_PROFILE_FIELD_ENFORCED_PATHS[flatField];
if (!path) {
return false;
}
return enforcedFlagAt(enforced, path[0], path[1]);
+2 -25
View File
@@ -25,7 +25,6 @@ import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { globalVars } from "@/utils/constants";
import { isRichTextPreviewMimeType } from "@/utils/mimetype";
import { invalidateDirMetadataCache } from "@/utils/metadataCache.js";
import { showShareDownloadPrompt as openShareDownloadPrompt } from "@/utils/download.js";
function directoryListingHasMediaChildren(req) {
return (
@@ -44,7 +43,7 @@ async function fetchShareItemWithParent(sharePassword) {
false
);
file.hash = state.shareInfo.hash;
mutations.setShareData({ passwordValid: true });
mutations.setShareData({ token: file.token, passwordValid: true });
if (file.type === "directory") {
return file;
@@ -77,6 +76,7 @@ async function fetchShareItemWithParent(sharePassword) {
const results = await Promise.all(promises);
file = results[0];
file.hash = state.shareInfo.hash;
mutations.setShareData({ token: results[0].token });
if (shouldFetchParent && results[1]?.items) {
file.parentDirItems = results[1].items;
}
@@ -424,9 +424,6 @@ export default {
mutations.replaceRequest(file);
document.title = `${globalVars.name} - ${this.$t("general.share")} - ${file.name}`;
await this.patchMediaMetadataIfNeeded(file);
if (this.$route.query.download === "true") {
this.promptShareDownload(file);
}
}
// === FILES-SPECIFIC INITIALIZATION ===
@@ -545,26 +542,6 @@ export default {
},
});
},
resolveShareDownloadPath(file) {
const subPath = state.shareInfo?.subPath;
if (subPath && subPath !== "/") {
return subPath;
}
if (file?.path && file.path !== "/") {
return file.path;
}
return "/";
},
promptShareDownload(file) {
const downloadPath = this.resolveShareDownloadPath(file);
openShareDownloadPrompt([{
path: downloadPath,
name: file?.name,
type: file?.type,
size: file?.size,
isDir: file?.type === "directory",
}]);
},
keyEvent(event) {
// F1!
if (event.key === "F1" && !event.ctrlKey && !event.metaKey) {
-1
View File
@@ -95,7 +95,6 @@ export default {
align-items: center;
justify-content: center;
height: unset !important;
padding-bottom: 2.5em;
}
.tool-content {
+2
View File
@@ -175,6 +175,7 @@ export default defineComponent({
{
path: state.shareInfo.subPath,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
},
false,
state.req.type || state.req.name,
@@ -209,6 +210,7 @@ export default defineComponent({
{
path: state.shareInfo.subPath,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
},
false,
state.req.type || state.req.name,
+1
View File
@@ -99,6 +99,7 @@ export default defineComponent({
{
path: state.shareInfo.subPath,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
},
false,
state.req.type || state.req.name,
+13 -3
View File
@@ -209,6 +209,7 @@ export default {
width: window.innerWidth,
lastSelected: {},
contextTimeout: null,
ctrKeyPressed: false,
clipboard: { items: [] },
internalClipboardTimestamp: 0,
isRectangleSelecting: false,
@@ -489,6 +490,7 @@ export default {
window.addEventListener("keydown", this.keyEvent);
window.addEventListener("resize", this.windowsResize);
window.addEventListener("click", this.clickClear);
window.addEventListener("keyup", this.clearCtrKey);
window.addEventListener("dragover", this.preventDefault);
window.addEventListener('paste', this.handlePaste);
document.addEventListener('mousemove', this.updateRectangleSelection, { passive: true });
@@ -539,6 +541,7 @@ export default {
window.removeEventListener("keydown", this.keyEvent);
window.removeEventListener("resize", this.windowsResize);
window.removeEventListener("click", this.clickClear);
window.removeEventListener("keyup", this.clearCtrKey);
window.removeEventListener("dragover", this.preventDefault);
window.removeEventListener('paste', this.handlePaste);
document.removeEventListener('mousemove', this.updateRectangleSelection);
@@ -763,6 +766,13 @@ export default {
}
}, 50);
},
clearCtrKey(event) {
const { ctrlKey, metaKey } = event;
const modifierKeys = ctrlKey || metaKey;
if (!modifierKeys) {
this.ctrKeyPressed = false;
}
},
keyEvent(event) {
const { key, ctrlKey, metaKey, altKey, which } = event;
const isArrowKey = key === 'ArrowUp' ||
@@ -800,6 +810,7 @@ export default {
}
if (modifierKeys) {
this.ctrKeyPressed = true;
const charKey = String.fromCharCode(which).toLowerCase();
switch (charKey) {
@@ -1219,8 +1230,8 @@ export default {
return;
}
// if control, meta, or shift is pressed, do not clear the selection
if (event.ctrlKey || event.metaKey || event.shiftKey) {
// if control or shift is pressed, do not clear the selection
if (this.ctrKeyPressed || event.shiftKey) {
return;
}
@@ -1441,7 +1452,6 @@ export default {
height: 100%;
display: flex;
flex-direction: column;
padding-right: 0.5em;
}
.add-padding {
+6 -1
View File
@@ -183,6 +183,7 @@ export default {
{
path: state.shareInfo.subPath,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
},
false,
typeHint,
@@ -196,7 +197,7 @@ export default {
if (this.pdfConvertable || getRawPreview || getHeicPreview) {
if (getters.isShare()) {
const previewPath = removeTrailingSlash(state.req.path);
return resourcesApi.getPreviewURLPublic(previewPath, "original", state.req.viewToken);
return resourcesApi.getPreviewURLPublic(previewPath, "original");
}
return (
`${resourcesApi.getPreviewURL(
@@ -214,6 +215,7 @@ export default {
{
path: state.shareInfo.subPath,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
},
false,
typeHint,
@@ -230,6 +232,7 @@ export default {
{
path: state.shareInfo.subPath,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
},
[state.req.path],
);
@@ -244,6 +247,7 @@ export default {
{
path: state.shareInfo.subPath,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
},
);
}
@@ -588,6 +592,7 @@ export default {
{
path: item.path,
hash: state.shareInfo?.hash,
token: state.shareInfo?.token,
},
false,
typeHint,
+1
View File
@@ -379,6 +379,7 @@ export default {
{
path: state.shareInfo.subPath,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
},
false,
typeHint,
-4
View File
@@ -2053,10 +2053,6 @@ export default {
'.audio-side-panel .lyric-line, ' +
'.audio-side-panel input[type="radio"], ' +
'.audio-side-panel label[for^="tab-"], ' +
'.audio-side-panel .mode-btn, ' +
'.audio-side-panel .repeat-one-btn, ' +
'.audio-side-panel .queue-item, ' +
'.audio-side-panel .clear-queue-btn, ' +
'.lyrics-mobile .lyric-line'
);
},
@@ -1,253 +0,0 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../test-setup";
const LISTING_TITLE = "Graham's Filebrowser - Files - playwright-files";
const DOUBLE_CLICK_WINDOW_MS = 600;
const FILE_A = "copyme.txt";
const FILE_B = "file.tar.gz";
const FOLDER_C = "myfolder";
const FILE_D = "1file1.txt";
async function openListing(page: Page) {
await page.goto("/files/");
await expect(page).toHaveTitle(LISTING_TITLE);
await listingItem(page, FILE_A).waitFor({ state: "visible" });
await listingItem(page, FILE_B).waitFor({ state: "visible" });
await listingItem(page, FOLDER_C).waitFor({ state: "visible" });
await listingItem(page, FILE_D).waitFor({ state: "visible" });
}
function listingItem(page: Page, name: string) {
return page.locator(`.listing-items .listing-item[aria-label="${name}"]`);
}
function nestedLabel(page: Page, name: string) {
return listingItem(page, name).locator(".name span");
}
function selectedItems(page: Page) {
return page.locator('.listing-items .listing-item[aria-selected="true"]');
}
function selectedCount(page: Page) {
return page.locator("#status-bar .status-info .button");
}
async function expectSelected(page: Page, names: string[]) {
await expect(selectedItems(page)).toHaveCount(names.length);
if (names.length > 0) {
await expect(selectedCount(page)).toHaveText(String(names.length));
}
for (const name of names) {
await expect(listingItem(page, name)).toHaveAttribute("aria-selected", "true");
}
await expect(page).toHaveTitle(LISTING_TITLE);
await expect(page).toHaveURL(/\/files\//);
}
async function dispatchBubblingClick(
target: Locator,
modifiers: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean } = {},
) {
await target.evaluate((el, mods) => {
el.dispatchEvent(
new MouseEvent("click", {
bubbles: true,
cancelable: true,
button: 0,
view: window,
ctrlKey: Boolean(mods.ctrlKey),
metaKey: Boolean(mods.metaKey),
shiftKey: Boolean(mods.shiftKey),
}),
);
}, modifiers);
}
async function waitForDoubleClickWindow(page: Page) {
await page.waitForTimeout(DOUBLE_CLICK_WINDOW_MS);
}
for (const modifier of [
{ name: "ctrlKey", init: { ctrlKey: true } },
{ name: "metaKey", init: { metaKey: true } },
] as const) {
test(`preserves ${modifier.name} selection when a nested label click bubbles without keydown`, async ({
page,
checkForErrors,
}) => {
await openListing(page);
await nestedLabel(page, FILE_A).click();
await expectSelected(page, [FILE_A]);
await dispatchBubblingClick(nestedLabel(page, FILE_B), modifier.init);
await expectSelected(page, [FILE_A, FILE_B]);
await dispatchBubblingClick(nestedLabel(page, FOLDER_C), modifier.init);
await expectSelected(page, [FILE_A, FILE_B, FOLDER_C]);
checkForErrors();
});
}
test("native Control hold selects distinct files and folders from nested and root targets", async ({
page,
checkForErrors,
}) => {
await openListing(page);
await page.keyboard.down("Control");
try {
await nestedLabel(page, FILE_A).click({ noWaitAfter: true });
await expect(listingItem(page, FILE_A)).toHaveAttribute("aria-selected", "true");
await expect(listingItem(page, FOLDER_C)).toHaveAttribute("aria-selected", "false");
await expect(listingItem(page, FILE_D)).toHaveAttribute("aria-selected", "false");
await expectSelected(page, [FILE_A]);
await listingItem(page, FOLDER_C).click({ noWaitAfter: true });
await expect(listingItem(page, FILE_A)).toHaveAttribute("aria-selected", "true");
await expect(listingItem(page, FOLDER_C)).toHaveAttribute("aria-selected", "true");
await expect(listingItem(page, FILE_D)).toHaveAttribute("aria-selected", "false");
await expectSelected(page, [FILE_A, FOLDER_C]);
await nestedLabel(page, FILE_D).click({ noWaitAfter: true });
await expect(listingItem(page, FILE_A)).toHaveAttribute("aria-selected", "true");
await expect(listingItem(page, FOLDER_C)).toHaveAttribute("aria-selected", "true");
await expect(listingItem(page, FILE_D)).toHaveAttribute("aria-selected", "true");
await expectSelected(page, [FILE_A, FOLDER_C, FILE_D]);
} finally {
await page.keyboard.up("Control");
}
checkForErrors();
});
test("holding Control before the listing mounts still allows additive selection", async ({
page,
checkForErrors,
}) => {
await page.goto("/settings");
await expect(page).toHaveTitle("Graham's Filebrowser - Settings");
await page.keyboard.down("Control");
try {
await page.goto("/files/");
await expect(page).toHaveTitle(LISTING_TITLE);
await listingItem(page, FILE_A).waitFor({ state: "visible" });
await listingItem(page, FILE_B).waitFor({ state: "visible" });
await nestedLabel(page, FILE_A).click({ noWaitAfter: true });
await expect(listingItem(page, FILE_A)).toHaveAttribute("aria-selected", "true");
await expect(listingItem(page, FILE_B)).toHaveAttribute("aria-selected", "false");
await nestedLabel(page, FILE_B).click({ noWaitAfter: true });
await expectSelected(page, [FILE_A, FILE_B]);
} finally {
await page.keyboard.up("Control");
}
checkForErrors();
});
test("Ctrl-click deselects only the clicked item after the double-click window", async ({
page,
checkForErrors,
}) => {
await openListing(page);
await nestedLabel(page, FILE_A).click();
await dispatchBubblingClick(nestedLabel(page, FILE_B), { ctrlKey: true });
await dispatchBubblingClick(nestedLabel(page, FOLDER_C), { ctrlKey: true });
await expectSelected(page, [FILE_A, FILE_B, FOLDER_C]);
await waitForDoubleClickWindow(page);
await dispatchBubblingClick(nestedLabel(page, FILE_B), { ctrlKey: true });
await expect(listingItem(page, FILE_B)).toHaveAttribute("aria-selected", "false");
await expectSelected(page, [FILE_A, FOLDER_C]);
checkForErrors();
});
test("unmodified click replaces the selection and a background click clears it", async ({
page,
checkForErrors,
}) => {
await openListing(page);
await nestedLabel(page, FILE_A).click();
await expectSelected(page, [FILE_A]);
await nestedLabel(page, FILE_B).click();
await expect(listingItem(page, FILE_A)).toHaveAttribute("aria-selected", "false");
await expectSelected(page, [FILE_B]);
await page.locator(".listing-items h2").first().click();
await expect(selectedItems(page)).toHaveCount(0);
await expect(page).toHaveTitle(LISTING_TITLE);
await expect(page).toHaveURL(/\/files\//);
checkForErrors();
});
test("releasing Control allows an ordinary background click to clear selection", async ({
page,
checkForErrors,
}) => {
await openListing(page);
await page.keyboard.down("Control");
try {
await nestedLabel(page, FILE_A).click({ noWaitAfter: true });
await listingItem(page, FOLDER_C).click({ noWaitAfter: true });
await expectSelected(page, [FILE_A, FOLDER_C]);
} finally {
await page.keyboard.up("Control");
}
await page.locator(".listing-items h2").first().click();
await expect(selectedItems(page)).toHaveCount(0);
await expect(page).toHaveTitle(LISTING_TITLE);
checkForErrors();
});
test("Shift-click selects a range of files", async ({ page, checkForErrors }) => {
await openListing(page);
const fileItems = page.locator('div[aria-label="File Items"] .listing-item');
await expect(fileItems.first()).toBeVisible();
expect(await fileItems.count()).toBeGreaterThanOrEqual(3);
const firstName = await fileItems.nth(0).getAttribute("aria-label");
const secondName = await fileItems.nth(1).getAttribute("aria-label");
const thirdName = await fileItems.nth(2).getAttribute("aria-label");
expect(firstName).toBeTruthy();
expect(secondName).toBeTruthy();
expect(thirdName).toBeTruthy();
if (!firstName || !secondName || !thirdName) {
throw new Error("Expected at least three named file items");
}
await fileItems.nth(0).click();
await expect(listingItem(page, firstName)).toHaveAttribute("aria-selected", "true");
await fileItems.nth(2).click({ modifiers: ["Shift"] });
await expectSelected(page, [firstName, secondName, thirdName]);
checkForErrors();
});
test("Ctrl+A selects all listing items", async ({ page, checkForErrors }) => {
await openListing(page);
await nestedLabel(page, FILE_A).click();
await expectSelected(page, [FILE_A]);
await page.keyboard.press("Control+a");
const allItems = page.locator(".listing-items .listing-item");
await expect(selectedItems(page)).toHaveCount(await allItems.count());
await expect(listingItem(page, FILE_A)).toHaveAttribute("aria-selected", "true");
await expect(listingItem(page, FILE_B)).toHaveAttribute("aria-selected", "true");
await expect(listingItem(page, FOLDER_C)).toHaveAttribute("aria-selected", "true");
await expect(page).toHaveTitle(LISTING_TITLE);
checkForErrors();
});