mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 13:12:00 -05:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
462f19af2e | ||
|
|
45b0ae5955 | ||
|
|
3e17afa337 | ||
|
|
23113bf740 | ||
|
|
0a330e2c38 | ||
|
|
bf18a264be | ||
|
|
81a8848165 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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 := ©FileRangeRejectingWriter{}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, ¬null, &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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+361
-433
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -119,6 +119,7 @@ export function buildPreviewResourceUrl(
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
},
|
||||
false,
|
||||
resolvedPath,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -95,7 +95,6 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: unset !important;
|
||||
padding-bottom: 2.5em;
|
||||
}
|
||||
|
||||
.tool-content {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -379,6 +379,7 @@ export default {
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
token: state.shareInfo.token,
|
||||
},
|
||||
false,
|
||||
typeHint,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user