diff --git a/CHANGELOG.md b/CHANGELOG.md index cdae9abc..d77634b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ 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.1 + + **Security**: + - [High] Symlink following on read paths no longer escapes source or user/share scope (GHSA-mgqf-5mf5-prfj) -- thanks @je-lv @KasperBuilds + - [Moderate] Absolute paths in API path/file parameters could bypass user scope and read files outside the source mount (GHSA-rqqq-wv83-rp74) -- thanks @Wei-Leo + - [Moderate] Logout did not invalidate session JWTs with equivalent Base64URL spellings (GHSA-8m35-wcjh-95q7) -- thanks @corbanvilla @soh3e @dderpym (This vulnerability was discovered as part of a U.C. Berkeley security research project by: Corban Villa, Sohee Kim, and Austin Chu) + - [Moderate] Stored XSS via unsanitized DOCX hyperlink in DocViewer (GHSA-9wm6-jcjh-3m8c) -- thanks @karen93shieh @je-lv @EclipsSec + - [Moderate] Revoked JWTs could still authenticate on public-share and withOrWithoutUser routes until natural expiry, bypassing logout and Api-permission revocation on that surface (GHSA-4wmj-rq3c-m65v) -- thanks @hypnguyen1209 + + **Notes**: + - GroupMap `SyncUserGroups` fix so JWT/OIDC/LDAP group memberships survive restart (#2742). + - Session renew is handled by client keep-alive. removed per-request `X-Renew-Token` header handling. + - Improvements to document thumbnail generation performance. + - behavior changes for typing to select files in listing view. + - next/previous buttons don't hide automatically on photos (#2767) + - added more actions available in advanced search (#2776) + + **Bugfixes**: + - GroupMap mutations (`SyncUserGroups`, add/remove group members) are write-through to SQL so JWT/OIDC/LDAP group memberships survive restart (#2742). + - `defaultEnabled` behavior change/fix so defaultEnabled means its always added to users on startup and login. (updated docs too) + - Long uploads/downloads no longer lose the session mid-transfer: proactive session JWT keep-alive renews before expiry (#2638). + - Disable auto logout/session expiry if there are active file transfers going (#2638) + - upload options get cut off on mobile (#2685) + - Uploading image: image gets cut-off (#2765) + - Process aborts (not just 500) during PDF thumbnail generation (#2763) + - PDF preview generation prevents file uploads from completing (#2752) + - Brings back double-tap to zoom images that was mistakenly removed. + - multiple embedded subtitles with same language bug (#2756) + - gallery view download button missing (#2767) + - fixed HEIC rotation regression from v1.5.x + - fixed raw image preview regression from v1.5.x + - html viewer takes full height + - Members without download permission receive 403 when opening text-based files despite OnlyOffice preview being enabled (#2777) + ## v2.0.0 This version represents the most significant change to date. It **requires** both a database migration and config structural changes. See the [migration guide](https://filebrowserquantum.com/en/docs/getting-started/v2/migration/) for step-by-step upgrade instructions, [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) for a full summary, and the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert legacy config. diff --git a/_docker/Dockerfile b/_docker/Dockerfile index 16633e7e..c7ae4529 100644 --- a/_docker/Dockerfile +++ b/_docker/Dockerfile @@ -5,6 +5,7 @@ ARG REVISION WORKDIR /app COPY ./backend ./ RUN apk update && apk add --no-cache gcc musl-dev +RUN go mod download && go run ./scripts/setup-gofitz-cgo ENV CGO_ENABLED=1 RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \ -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \ diff --git a/backend/.air.toml b/backend/.air.toml index 6396ca59..cf9f07eb 100644 --- a/backend/.air.toml +++ b/backend/.air.toml @@ -2,6 +2,7 @@ root = "." tmp_dir = "tmp" [build] + pre_cmd = ["go run ./scripts/setup-gofitz-cgo"] cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ." entrypoint = "./tmp/filebrowser" full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml" diff --git a/backend/.air.windows.toml b/backend/.air.windows.toml index a69b309d..ecbf08e9 100644 --- a/backend/.air.windows.toml +++ b/backend/.air.windows.toml @@ -2,7 +2,8 @@ root = "." tmp_dir = "tmp" [build] - cmd = "go build -o ./tmp/filebrowser.exe --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ." + pre_cmd = ["go run ./scripts/setup-gofitz-cgo"] + cmd = "go build -o ./tmp/filebrowser.exe --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ." entrypoint = "./tmp/filebrowser.exe" full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser.exe -c test_config.yaml" include_ext = ["go", "tpl", "tmpl", "html"] diff --git a/backend/.goreleaser.yaml b/backend/.goreleaser.yaml index a37b6586..81f327b6 100644 --- a/backend/.goreleaser.yaml +++ b/backend/.goreleaser.yaml @@ -2,6 +2,11 @@ project_name: filebrowser version: 2 +before: + hooks: + - go mod download + - go run ./scripts/setup-gofitz-cgo + builds: # Build configuration for linux amd64 - id: linux-amd64 @@ -66,7 +71,8 @@ builds: - id: windows env: - CGO_ENABLED=1 - - CC=zig cc -target x86_64-windows + # windows-gnu avoids zig rejecting Go's -tsaware when cross-compiling from macOS + - CC=zig cc -target x86_64-windows-gnu -lc tags: - analytics - mupdf @@ -93,7 +99,7 @@ builds: goarch: - amd64 - # Build configuration for macos without upx + # Build configuration for macos without upx (no mupdf: CGO cross-compile from Linux CI is unsupported) - id: macos ldflags: *ldflags main: main.go diff --git a/backend/cmd/admin_share_test.go b/backend/cmd/admin_share_test.go index 458dc128..93458f9a 100644 --- a/backend/cmd/admin_share_test.go +++ b/backend/cmd/admin_share_test.go @@ -87,7 +87,7 @@ func TestAdminHasSharePermissionAfterSettingsMigration(t *testing.T) { assertSourceSidebarLinkCount(t, &admin, 3) } -func TestGrahamNoAccessAfterSettingsMigrationStartup_dockerSourcePaths(t *testing.T) { +func TestGrahamGainsAccessAfterSettingsMigrationStartup_dockerSourcePaths(t *testing.T) { t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "") configPath := filepath.Join(t.TempDir(), "config.yaml") configYAML := `server: @@ -136,19 +136,29 @@ auth: if err != nil { t.Fatal(err) } + foundScope := false for _, scope := range graham.BackendScopes { if scope.Path == fixtureAccessSource { - t.Fatalf("graham gained access scope: %#v", graham.BackendScopes) + foundScope = true + break } } + if !foundScope { + t.Fatalf("graham should gain access scope after validateUserInfo: %#v", graham.BackendScopes) + } + foundLink := false for _, link := range graham.SidebarLinks { - if link.Name == "access" { - t.Fatalf("graham has access sidebar link: %#v", graham.SidebarLinks) + if link.Name == "access" || link.SourceName == fixtureAccessSource { + foundLink = true + break } } + if !foundLink { + t.Fatalf("graham should have access sidebar link after validateUserInfo: %#v", graham.SidebarLinks) + } } -func TestGrahamNoAccessAfterSettingsMigrationStartup(t *testing.T) { +func TestGrahamGainsAccessAfterSettingsMigrationStartup(t *testing.T) { t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "") settings.Initialize(settingsMigrationConfigPath(t)) settings.Env.IsPlaywright = true @@ -174,16 +184,26 @@ func TestGrahamNoAccessAfterSettingsMigrationStartup(t *testing.T) { if err != nil { t.Fatal(err) } + foundScope := false for _, scope := range graham.BackendScopes { if scope.Path == fixtureAccessSource { - t.Fatalf("graham gained access scope after validateUserInfo: %#v", graham.BackendScopes) + foundScope = true + break } } + if !foundScope { + t.Fatalf("graham should gain access scope after validateUserInfo: %#v", graham.BackendScopes) + } + foundLink := false for _, link := range graham.SidebarLinks { - if link.Name == "access" { - t.Fatalf("graham has access sidebar link after validateUserInfo: %#v", graham.SidebarLinks) + if link.Name == "access" || link.SourceName == fixtureAccessSource { + foundLink = true + break } } + if !foundLink { + t.Fatalf("graham should have access sidebar link after validateUserInfo: %#v", graham.SidebarLinks) + } if len(graham.SidebarLinks) == 0 { t.Fatal("graham should have sidebar links after migration so frontend does not fall back to all sources") } diff --git a/backend/cmd/user.go b/backend/cmd/user.go index aa0b3da3..c1b78611 100644 --- a/backend/cmd/user.go +++ b/backend/cmd/user.go @@ -127,51 +127,9 @@ func dedupeFields(fields []string) []string { } func updateUserScopes(user *users.User) bool { - newScopes := []users.BackendScope{} - seen := make(map[string]struct{}) - hadScopes := len(user.BackendScopes) > 0 - - // Build map for existing scopes by Name - existing := make(map[string]users.BackendScope) - for _, s := range user.BackendScopes { - existing[s.Path] = s - } - - // Preserve order by using Config.Server.Sources - for _, src := range settings.Config.Server.Sources { - existingScope, ok := existing[src.Path] - if ok { - // If scope is empty and there's a default, apply default - if existingScope.Scope == "" { - existingScope.Scope = src.Config.DefaultUserScope - } - } else if src.Config.DefaultEnabled { - // Only seed default-enabled sources for users with no scopes yet. - // Partial-scope users (e.g. migrated graham) must not gain extra sources on startup. - if hadScopes { - continue - } - existingScope.Scope = src.Config.DefaultUserScope - } else { - continue - } - newScopes = append(newScopes, users.BackendScope{ - Path: src.Path, - Scope: existingScope.Scope, - Permissions: existingScope.Permissions, - }) - seen[src.Path] = struct{}{} - } - - // Preserve user-defined scopes not matching current sources, append to end - for _, s := range user.BackendScopes { - if _, ok := seen[s.Path]; !ok { - newScopes = append(newScopes, s) - } - } + newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes) changed := !reflect.DeepEqual(user.BackendScopes, newScopes) user.BackendScopes = newScopes - return changed } diff --git a/backend/cmd/user_scopes_test.go b/backend/cmd/user_scopes_test.go index 75404b5a..040ab270 100644 --- a/backend/cmd/user_scopes_test.go +++ b/backend/cmd/user_scopes_test.go @@ -10,7 +10,7 @@ import ( "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" ) -func TestUpdateUserScopes_preservesPartialScopes(t *testing.T) { +func TestUpdateUserScopes_mergesMissingDefaultEnabled(t *testing.T) { settings.Initialize(settingsMigrationConfigPath(t)) alignSettingsSourcesForMigrationFixture(t) @@ -22,18 +22,31 @@ func TestUpdateUserScopes_preservesPartialScopes(t *testing.T) { }, } - updateUserScopes(user) - if len(user.BackendScopes) != 2 { - t.Fatalf("scopes=%#v want 2 partial scopes", user.BackendScopes) + if !updateUserScopes(user) { + t.Fatal("expected missing defaultEnabled access source to be merged") } + if len(user.BackendScopes) != 3 { + t.Fatalf("scopes=%#v want 3 including access", user.BackendScopes) + } + found := false for _, scope := range user.BackendScopes { if scope.Path == fixtureAccessSource { - t.Fatalf("access source should not be added to partial-scope user: %#v", user.BackendScopes) + found = true + break + } + } + if !found { + t.Fatalf("access source should be added for defaultEnabled: %#v", user.BackendScopes) + } + // Preserve existing scope paths + for _, scope := range user.BackendScopes { + if scope.Path == fixturePlaywrightSource && scope.Scope != "/myfolder" { + t.Fatalf("playwright scope path changed: %#v", scope) } } } -func TestMigrationGraham_noAccessScopeOrSidebarLink(t *testing.T) { +func TestMigrationGraham_gainsAccessAfterScopeMerge(t *testing.T) { t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "") settings.Initialize(settingsMigrationConfigPath(t)) settings.Env.IsPlaywright = true @@ -59,35 +72,39 @@ func TestMigrationGraham_noAccessScopeOrSidebarLink(t *testing.T) { if graham.BackendSourcePermissions == nil { t.Fatal("expected backend source permissions after migration") } + // Migration fixture itself may omit access; merge happens in updateUserScopes. if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; ok { - t.Fatalf("graham should not have access permissions after migration: %#v", graham.BackendSourcePermissions) - } - - for _, link := range usersidebar.FrontendLinks(graham.SidebarLinks, true) { - if link.Name == "access" { - t.Fatalf("graham should not have access sidebar link after migration: %#v", graham.SidebarLinks) - } + t.Fatalf("graham should not have access permissions from migration alone: %#v", graham.BackendSourcePermissions) } updateUserScopes(graham) updateSourcePermissions(graham) updateSidebarLinks(graham) + foundScope := false for _, scope := range graham.BackendScopes { if scope.Path == fixtureAccessSource { - t.Fatalf("graham should not gain access scope: %#v", graham.BackendScopes) + foundScope = true + break } } - if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; ok { - t.Fatalf("graham should not have access source permissions: %#v", graham.BackendSourcePermissions) + if !foundScope { + t.Fatalf("graham should gain access scope after merge: %#v", graham.BackendScopes) + } + if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; !ok { + t.Fatalf("graham should have access source permissions after merge: %#v", graham.BackendSourcePermissions) } - frontendLinks := usersidebar.FrontendLinks(graham.SidebarLinks, true) - for _, link := range frontendLinks { - if link.Name == "access" { - t.Fatalf("graham should not have access sidebar link: %#v", frontendLinks) + foundLink := false + for _, link := range usersidebar.FrontendLinks(graham.SidebarLinks, true) { + if link.Name == "access" || link.SourceName == fixtureAccessSource { + foundLink = true + break } } + if !foundLink { + t.Fatalf("graham should have access sidebar link after merge: %#v", graham.SidebarLinks) + } } func TestUpdateUserScopes_seedsDefaultEnabledWhenEmpty(t *testing.T) { diff --git a/backend/cmd/user_test.go b/backend/cmd/user_test.go index 40c9fb53..0b8cab9f 100644 --- a/backend/cmd/user_test.go +++ b/backend/cmd/user_test.go @@ -45,9 +45,12 @@ func TestUpdateUserScopes_Phases(t *testing.T) { }, expectedPhase1: []users.BackendScope{ {Scope: "/home", Path: "/pathA"}, + {Scope: "/defaultB", Path: "/pathB"}, }, + // Phase2: both already present after phase1 merge. expectedPhase2: []users.BackendScope{ {Scope: "/home", Path: "/pathA"}, + {Scope: "/defaultB", Path: "/pathB"}, }, }, { @@ -61,6 +64,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) { {Scope: "/defaultB", Path: "/pathB"}, }, expectedPhase2: []users.BackendScope{ + {Scope: "/defaultA", Path: "/pathA"}, {Scope: "/defaultB", Path: "/pathB"}, }, }, @@ -95,6 +99,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) { {Scope: "/data", Path: "/somethingElse"}, }, expectedPhase2: []users.BackendScope{ + {Scope: "/defaultA", Path: "/pathA"}, {Scope: "/home", Path: "/pathB"}, {Scope: "/data", Path: "/somethingElse"}, }, @@ -108,6 +113,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) { {Scope: "/defaultB", Path: "/pathB"}, }, expectedPhase2: []users.BackendScope{ + {Scope: "/defaultA", Path: "/pathA"}, {Scope: "/defaultB", Path: "/pathB"}, }, }, diff --git a/backend/config.media.yaml b/backend/config.media.yaml deleted file mode 100644 index e5501f23..00000000 --- a/backend/config.media.yaml +++ /dev/null @@ -1,10 +0,0 @@ -userDefaults: - preview: - image: true - video: true - audio: true - motionVideoPreview: true - office: true - popup: true - folder: true - models: true diff --git a/backend/go.mod b/backend/go.mod index 5031eb5e..c9015880 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,7 +3,7 @@ module github.com/gtsteffaniak/filebrowser/backend go 1.26.0 require ( - github.com/alecthomas/kong v1.16.0 + github.com/alecthomas/kong v1.16.1 github.com/asdine/storm/v3 v3.2.1 github.com/coreos/go-oidc/v3 v3.20.0 github.com/coreos/go-systemd/v22 v22.7.0 @@ -28,17 +28,17 @@ require ( github.com/stretchr/testify v1.11.1 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.6 - golang.org/x/crypto v0.54.0 - golang.org/x/image v0.44.0 - golang.org/x/mod v0.38.0 - golang.org/x/net v0.57.0 + golang.org/x/crypto v0.55.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.48.0 + golang.org/x/tools v0.49.0 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.55.0 + modernc.org/sqlite v1.56.0 ) require ( @@ -108,7 +108,6 @@ require ( github.com/dave/dst v0.27.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect - github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dlclark/regexp2/v2 v2.2.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 @@ -147,7 +146,7 @@ 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.2.6 // indirect + github.com/go-webauthn/x v0.2.8 // 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.2 // indirect @@ -155,7 +154,7 @@ require ( github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/gohugoio/hugo v0.164.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/golang/geo v0.0.0-20260713102120-857a528af641 // indirect + github.com/golang/geo v0.0.0-20260810185319-42794aff7523 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/golangci/asciicheck v0.5.0 // indirect github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect @@ -204,7 +203,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/macabu/inamedparam v0.2.0 // indirect github.com/magiconair/properties v1.8.6 // indirect - github.com/mailru/easyjson v0.9.1 // indirect github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect github.com/manuelarte/funcorder v0.6.0 // indirect github.com/maratori/testableexamples v1.0.1 // indirect @@ -296,14 +294,14 @@ require ( golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect honnef.co/go/tools v0.7.0 // indirect - modernc.org/libc v1.74.4 // indirect + modernc.org/libc v1.75.3 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect + modernc.org/memory v1.12.0 // indirect mvdan.cc/gofumpt v0.9.2 // indirect mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect sigs.k8s.io/yaml v1.3.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index eb1a001d..a664111d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -75,6 +75,10 @@ github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM= github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g= github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= +github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0= +github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= @@ -85,22 +89,16 @@ github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsu github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM= github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM= -github.com/air-verse/air v1.67.1 h1:/n3nLDvGrbQbT5iFL4sTOGCjze4BAs+6HkLFwj/XLd8= -github.com/air-verse/air v1.67.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY= -github.com/air-verse/air v1.67.3 h1:/mfgjG66EkLosKebWjsDtD5tINZcpD2ilRctrA2/aRA= -github.com/air-verse/air v1.67.3/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs= github.com/air-verse/air v1.67.4 h1:pkWBQues0094VMgUZ9MboNfS1vyCBEXeU6QxuD6/PwU= github.com/air-verse/air v1.67.4/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= -github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= -github.com/alecthomas/kong v1.16.0 h1:g92/kUxBcdcTPOM79yE63viJgtcp5dNyrB3/O2cjYT4= -github.com/alecthomas/kong v1.16.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/kong v1.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E= +github.com/alecthomas/kong v1.16.1/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -122,8 +120,6 @@ github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEW github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M= -github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 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= @@ -148,28 +144,24 @@ github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7 github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU= github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI= github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA= -github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5kujw= -github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg= +github.com/bep/golocales v0.2.0 h1:4H1H5UPw3ainpj5zykeEfiMRQngyaIC/t+I4Dvn+fvE= +github.com/bep/golocales v0.2.0/go.mod h1:Hl78nje8mNL3LzLeJvYN9NsIZgyFJGrGfvgO9r1+mwE= github.com/bep/goportabletext v0.2.0 h1:CZ9f8jADBWqHwBymQiJJPCTSV/tHSA+PYzlUf86Yze0= -github.com/bep/gowebp v0.4.0 h1:QihuVnvIKbRoeBNQkN0JPMM8ClLmD6V2jMftTFwSK3Q= -github.com/bep/gowebp v0.4.0/go.mod h1:95gtYkAA8iIn1t3HkAPurRCVGV/6NhgaHJ1urz0iIwc= -github.com/bep/helpers v0.6.0 h1:qtqMCK8XPFNM9hp5Ztu9piPjxNNkk8PIyUVjg6v8Bsw= -github.com/bep/helpers v0.6.0/go.mod h1:IOZlgx5PM/R/2wgyCatfsgg5qQ6rNZJNDpWGXqDR044= +github.com/bep/goportabletext v0.2.0/go.mod h1:xDeA5+qcgKzJq6Q6XjAiBKtxLD3Yn7f6XP4joD3J3qU= github.com/bep/helpers v0.12.0 h1:tD6V2DQW0B+FUynF2etR/106S/TO9akm+vA/Hk24GxY= -github.com/bep/imagemeta v0.12.0 h1:ARf+igs5B7pf079LrqRnwzQ/wEB8Q9v4NSDRZO1/F5k= -github.com/bep/imagemeta v0.12.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8= +github.com/bep/helpers v0.12.0/go.mod h1:PfE7MGdA8sSQ19nyDh4tYbs5rAlStlJaDI21f/fnNps= github.com/bep/imagemeta v0.17.2 h1:fDyXM1eAqCfBeqGLqS6UsN4OfuLM0cdu70KuLCehjOg= -github.com/bep/lazycache v0.8.0 h1:lE5frnRjxaOFbkPZ1YL6nijzOPPz6zeXasJq8WpG4L8= -github.com/bep/lazycache v0.8.0/go.mod h1:BQ5WZepss7Ko91CGdWz8GQZi/fFnCcyWupv8gyTeKwk= +github.com/bep/imagemeta v0.17.2/go.mod h1:+Hlp195TfZpzsqCxtDKTG6eWdyz2+F2V/oCYfr3CZKA= 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.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE= -github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro= github.com/bep/overlayfs v0.11.0 h1:aymHDGC0CHpvn0XvTfgpK6skCp16oMi+tdUF32l6pPs= -github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI= -github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0= +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.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.24.5 h1:654xBVHc23gJMAgOTkPNoCVfiRxuIOAUnAZFtopqJ4w= github.com/bits-and-blooms/bitset v1.24.5/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= @@ -243,7 +235,6 @@ github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEy 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= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -251,8 +242,6 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg= github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E= -github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc= -github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= 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.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= @@ -286,9 +275,8 @@ github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= github.com/evanoberholster/imagemeta v1.0.0 h1:FlSihlsjkWIoS83wmgPOP3CbAxlRpxM8jQGZgLu+MWQ= github.com/evanoberholster/imagemeta v1.0.0/go.mod h1:2ITZJjD1ygzWIhepvn8coRvpaFcnDNk3E6hx6v7z8DU= -github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs= -github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/evanw/esbuild v0.28.1 h1:ds+yuRyUaZGx++GR56CrCeuXh8PVhVM4xq8v7PNELFc= +github.com/evanw/esbuild v0.28.1/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= @@ -308,11 +296,8 @@ github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd 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.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= -github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g= -github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g= github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0= github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= @@ -347,7 +332,6 @@ github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1 github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= @@ -404,8 +388,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= -github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= -github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= +github.com/go-webauthn/x v0.2.8 h1:x7whGNJdgDJzJ06uItJjiqvneS0ljcvT3HGkgL+8g3M= +github.com/go-webauthn/x v0.2.8/go.mod h1:mdH0BXq0eKmAtBc7QTr37Yv+qGfsVf/ai8M3R+aXyUE= 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= @@ -419,30 +403,22 @@ github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY= -github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ= +github.com/gohugoio/gift v0.2.0 h1:vA31pP0rTVmBxBrhpY3WEt+4zM4g+1sDqYeemwsYeqc= +github.com/gohugoio/gift v0.2.0/go.mod h1:1Mrm5CjF33KpD749Dwj+UAjWZ3LC6cBXGuTMa5XwoP4= github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 h1:pxlAea9eRwuAnt/zKbGqlFO2ZszpIe24YpOVLf+N+4I= -github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= -github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= +github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6/go.mod h1:m5hu1im5Qc7LDycVLvee6MPobJiRLBYHklypFJR0/aE= +github.com/gohugoio/go-radix v1.2.0 h1:D5GTk8jIoeXirBSc2P4E4NdHKDrenk9k9N0ctU5Yrhg= +github.com/gohugoio/go-radix v1.2.0/go.mod h1:k6vDa0ebpbpgtzSj9lPGJcA4AZwJ9xUNObUy2vczPFM= github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= -github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ3Vs= -github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI= github.com/gohugoio/httpcache v0.8.0 h1:hNdsmGSELztetYCsPVgjA960zSa4dfEqqF/SficorCU= -github.com/gohugoio/hugo v0.149.1 h1:uWOc8Ve4h4e48FyYhBquRoHCJviyxA5yGrFJLT48yio= -github.com/gohugoio/hugo v0.149.1/go.mod h1:HS6BP6e8FGxungP4CHC3zeLDvhBLnTJIjHJZWTZjs7o= +github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI= github.com/gohugoio/hugo v0.164.0 h1:oDElnVJNvDMpEOeoxBjzW8JTFk7u+gJj2kG74BsfXlM= github.com/gohugoio/hugo v0.164.0/go.mod h1:QPBCN6Gntmm6YrYJEnTasaZjnMLRkv8YoyCOw7mo9JE= -github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0 h1:dco+7YiOryRoPOMXwwaf+kktZSCtlFtreNdiJbETvYE= -github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0/go.mod h1:CRrxQTKeM3imw+UoS4EHKyrqB7Zp6sAJiqHit+aMGTE= github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus= -github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY= -github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog= +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= -github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc= -github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4= -github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo= -github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0= +github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0/go.mod h1:ob9PCHy/ocsQhTz68uxhyInaYCbbVNpOOrJkIoSeD+8= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -450,8 +426,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= -github.com/golang/geo v0.0.0-20260713102120-857a528af641 h1:cpoobkgVGCE6bC5kJkOfwfM6hAtZuvoBjnCLECxG4AU= -github.com/golang/geo v0.0.0-20260713102120-857a528af641/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8= +github.com/golang/geo v0.0.0-20260810185319-42794aff7523 h1:hMV5bYZzc9JXsuiaUjI0UALnCdg5gxCb5rS16KUfyVA= +github.com/golang/geo v0.0.0-20260810185319-42794aff7523/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -536,8 +512,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -607,8 +583,6 @@ github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgY github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -673,8 +647,6 @@ github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddB github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= -github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE= github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww= @@ -714,8 +686,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -736,21 +706,18 @@ github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8= github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0= github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg= -github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= -github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= -github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= -github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA= -github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8= -github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= +github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= @@ -766,19 +733,14 @@ github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2D github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= -github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -823,8 +785,6 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -864,8 +824,6 @@ github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQyt 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.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= -github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -899,25 +857,21 @@ 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.2 h1:vnY3nTulEAbCAAlxTxPPDkzG24rsq31SOzp63yT+7mo= -github.com/tdewolff/minify/v2 v2.24.2/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE= github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58= -github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I= -github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= +github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0= github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg= github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= -github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE= github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= +github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA= github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU= -github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= -github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 h1:SiHe5XLTn9sFWJ5pBwJ5FN/4j34q9ZlOAD//kMoMYp0= github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M= github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= @@ -940,8 +894,6 @@ github.com/uudashr/iface v1.4.2 h1:06Vq5RKVYThBsj0Bnw4oasMjD1r+7CE/bcKOA8dVSvg= github.com/uudashr/iface v1.4.2/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= -github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= -github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= 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= @@ -962,9 +914,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.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= -github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/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= @@ -1005,8 +956,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U 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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1025,8 +976,8 @@ golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjO golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= -golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +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/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -1049,8 +1000,8 @@ 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.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +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-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1089,8 +1040,8 @@ 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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +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.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1175,8 +1126,8 @@ 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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +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.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1229,8 +1180,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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +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= @@ -1316,8 +1267,6 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= @@ -1349,30 +1298,30 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= -modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= -modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= -modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +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= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= -modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +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.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= -modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/libc v1.75.3 h1:vCqT5+R0jPXMnvMkGo0T2zXvFNth+lYXVCx5X7CCX/g= +modernc.org/libc v1.75.3/go.mod h1:MjAX68G+0oufI+hNuh0QXcK+Ap+sL8bNPPcIC6EqOfo= 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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU= +modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 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.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= -modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= 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= diff --git a/backend/internal/adapters/fs/files/files.go b/backend/internal/adapters/fs/files/files.go index 3f34d480..f923e54b 100644 --- a/backend/internal/adapters/fs/files/files.go +++ b/backend/internal/adapters/fs/files/files.go @@ -154,7 +154,7 @@ func checkPermissionsImpl(opts utils.FileOptions, user *users.User, s *Service) return "", "", errors.ErrAccessDenied } - indexPath := utils.JoinPathAsUnix(userScope, safePath) + indexPath := utils.JoinScopedIndexPath(userScope, safePath) parsedPath, err := utils.ParseSanitizedIndexPath(indexPath, true) if err != nil { return "", "", errors.ErrAccessDenied @@ -190,7 +190,7 @@ func GetDirItems(opts utils.FileOptions, user *users.User) (Items, error) { func getDirItemsImpl(opts utils.FileOptions, user *users.User, s *Service) (Items, error) { items := Items{} - indexPath, _, topLevelErr := checkPermissionsImpl(opts, user, s) + indexPath, userScope, topLevelErr := checkPermissionsImpl(opts, user, s) accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != "" if topLevelErr != nil && !accessRulesErr { return items, topLevelErr @@ -200,14 +200,18 @@ func getDirItemsImpl(opts utils.FileOptions, user *users.User, s *Service) (Item if idx == nil { return items, fmt.Errorf("could not get index: %v ", opts.Source) } - info, err := idx.GetFileInfo(indexing.FileInfoRequest{ + infoReq := indexing.FileInfoRequest{ IndexPath: indexPath, FollowSymlinks: opts.FollowSymlinks, ShowHidden: opts.ShowHidden, HideFileExt: opts.HideFileExt, Expand: true, SkipExtendedAttrs: true, - }) + } + if opts.FollowSymlinks { + infoReq.BoundIndexPath = userScope + } + info, err := idx.GetFileInfo(infoReq) if err != nil { return items, err // Path excluded by index rules OR doesn't exist } @@ -261,14 +265,18 @@ func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (* } // Layer 2: INDEX RULES (global) // Get file info using unified entry point (applies IsViewable/ShouldSkip) - info, err := idx.GetFileInfo(indexing.FileInfoRequest{ + fileInfoReq := indexing.FileInfoRequest{ IndexPath: indexPath, FollowSymlinks: opts.FollowSymlinks, ShowHidden: opts.ShowHidden, HideFileExt: opts.HideFileExt, Expand: opts.Expand, SkipExtendedAttrs: opts.SkipExtendedAttrs, - }) + } + if opts.FollowSymlinks { + fileInfoReq.BoundIndexPath = userScope + } + info, err := idx.GetFileInfo(fileInfoReq) if err != nil { return response, err // Path excluded by index rules OR doesn't exist } @@ -285,7 +293,15 @@ func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (* // Build response response.FileInfo = *info - response.RealPath = filepath.Join(idx.Path, indexPath) + if opts.FollowSymlinks && info.Type != "directory" { + realPath, _, err := idx.GetRealPathScoped(userScope, indexPath) + if err != nil { + return response, err + } + response.RealPath = realPath + } else { + response.RealPath = utils.JoinUnderSourceRoot(idx.Path, indexPath) + } response.Source = opts.Source if user.Permissions.Share && opts.ShowSharedAttr { for i := range response.Files { diff --git a/backend/internal/auth/jwt_strict_test.go b/backend/internal/auth/jwt_strict_test.go new file mode 100644 index 00000000..f0ef3c76 --- /dev/null +++ b/backend/internal/auth/jwt_strict_test.go @@ -0,0 +1,124 @@ +package auth + +import ( + "bytes" + "encoding/base64" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/gtsteffaniak/filebrowser/backend/internal/database/users" + "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" +) + +const base64URLAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + +func decodeJWTBase64URL(s string) ([]byte, error) { + switch len(s) % 4 { + case 2: + s += "==" + case 3: + s += "=" + } + return base64.URLEncoding.DecodeString(s) +} + +// equivalentSpelling returns a different compact JWT string with the same decoded signature bytes. +func equivalentSpelling(token string) string { + parts := strings.Split(token, ".") + if len(parts) != 3 { + panic("equivalentSpelling: invalid JWT") + } + header, payload, signature := parts[0], parts[1], parts[2] + orig, err := decodeJWTBase64URL(signature) + if err != nil { + panic("equivalentSpelling: decode canonical signature: " + err.Error()) + } + + last := signature[len(signature)-1] + for _, c := range base64URLAlphabet { + if byte(c) == last { + continue + } + trial := signature[:len(signature)-1] + string(c) + dec, err := decodeJWTBase64URL(trial) + if err != nil || !bytes.Equal(dec, orig) { + continue + } + respelled := header + "." + payload + "." + trial + if respelled != token { + return respelled + } + } + panic("equivalentSpelling: no alternate spelling found") +} + +func invalidSignatureSpelling(token string) string { + parts := strings.Split(token, ".") + header, payload, signature := parts[0], parts[1], parts[2] + idx := strings.IndexByte(base64URLAlphabet, signature[len(signature)-1]) + replacement := base64URLAlphabet[((idx&0b110000)+16)%64] + return header + "." + payload + "." + signature[:len(signature)-1] + string(replacement) +} + +func parseSessionToken(tokenString string) error { + keyFunc := func(token *jwt.Token) (interface{}, error) { + return []byte(settings.Config.Auth.Key), nil + } + var tk users.AuthToken + _, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc) + return err +} + +func TestDecodeStrictRejectsEquivalentSpelling(t *testing.T) { + origKey := settings.Config.Auth.Key + origStrict := jwt.DecodeStrict + settings.Config.Auth.Key = "test-signing-key-strict-decode" + jwt.DecodeStrict = true + t.Cleanup(func() { + settings.Config.Auth.Key = origKey + jwt.DecodeStrict = origStrict + }) + + user := &users.User{ID: 1, FrontendUser: users.FrontendUser{Username: "alice"}} + canonical, _, err := MakeSignedTokenAPI(user, "WEB_TOKEN_test", time.Hour, user.Permissions, false) + if err != nil { + t.Fatalf("MakeSignedTokenAPI: %v", err) + } + + if err := parseSessionToken(canonical); err != nil { + t.Fatalf("canonical token should parse under strict mode: %v", err) + } + + respelled := equivalentSpelling(canonical) + if err := parseSessionToken(respelled); err == nil { + t.Fatal("equivalent spelling should be rejected when jwt.DecodeStrict is true") + } + + tampered := invalidSignatureSpelling(canonical) + if err := parseSessionToken(tampered); err == nil { + t.Fatal("tampered signature should be rejected") + } +} + +func TestEquivalentSpellingParsesWithoutStrictMode(t *testing.T) { + origKey := settings.Config.Auth.Key + origStrict := jwt.DecodeStrict + settings.Config.Auth.Key = "test-signing-key-strict-decode" + jwt.DecodeStrict = false + t.Cleanup(func() { + settings.Config.Auth.Key = origKey + jwt.DecodeStrict = origStrict + }) + + user := &users.User{ID: 1, FrontendUser: users.FrontendUser{Username: "alice"}} + canonical, _, err := MakeSignedTokenAPI(user, "WEB_TOKEN_test", time.Hour, user.Permissions, false) + if err != nil { + t.Fatalf("MakeSignedTokenAPI: %v", err) + } + respelled := equivalentSpelling(canonical) + if err := parseSessionToken(respelled); err != nil { + t.Fatalf("equivalent spelling should parse without strict mode (documents pre-fix behavior): %v", err) + } +} diff --git a/backend/internal/database/access/access.go b/backend/internal/database/access/access.go index dc74dce4..f4e2173d 100644 --- a/backend/internal/database/access/access.go +++ b/backend/internal/database/access/access.go @@ -89,11 +89,39 @@ func (s *Storage) SetSQLStore(sqlStore SQLPersister) { s.sqlStore = sqlStore } -// Flush is a no-op; access rule updates already write through sqlStore when configured. +// Flush is a no-op; access rule and group updates already write through sqlStore when configured. func (s *Storage) Flush() error { return nil } +// persistGroupSQLNL upserts or deletes one groups row to match in-memory state. +// Caller must hold s.mux. If the group is absent from s.Groups, it is deleted from SQL. +func (s *Storage) persistGroupSQLNL(groupname string) { + if s.sqlStore == nil { + return + } + members, ok := s.Groups[groupname] + if !ok { + if err := s.sqlStore.DeleteGroup(groupname); err != nil { + logger.Errorf("failed to delete group %q from sql: %v", groupname, err) + } + return + } + if err := s.sqlStore.SaveGroup(groupname, members); err != nil { + logger.Errorf("failed to save group %q: %v", groupname, err) + } +} + +// ensureGroupExistsNL creates an empty in-memory group if missing and write-through persists it. +// Caller must hold s.mux. +func (s *Storage) ensureGroupExistsNL(groupname string) { + if _, ok := s.Groups[groupname]; ok { + return + } + s.Groups[groupname] = make(StringSet) + s.persistGroupSQLNL(groupname) +} + // NewStorage creates a new Storage instance. func NewStorage(usersStore *users.Storage) *Storage { var s = &Storage{ @@ -235,13 +263,11 @@ func (s *Storage) AllowUser(sourcePath string, indexPath utils.IndexPath, userna } // DenyGroup adds a group to the deny list for a given source and index path. +// Unknown groups are auto-created (empty) so admins can configure rules before first SSO sync. func (s *Storage) DenyGroup(sourcePath string, indexPath utils.IndexPath, groupname string) error { s.mux.Lock() defer s.mux.Unlock() - _, ok := s.Groups[groupname] - if !ok { - return fmt.Errorf("group '%s' does not exist", groupname) - } + s.ensureGroupExistsNL(groupname) rule := s.getOrCreateRuleNL(sourcePath, indexPath) if _, ok := rule.Deny.Groups[groupname]; ok { return errors.ErrExist @@ -253,13 +279,11 @@ func (s *Storage) DenyGroup(sourcePath string, indexPath utils.IndexPath, groupn } // AllowGroup adds a group to the allow list for a given source and index path. +// Unknown groups are auto-created (empty) so admins can configure rules before first SSO sync. func (s *Storage) AllowGroup(sourcePath string, indexPath utils.IndexPath, groupname string) error { s.mux.Lock() defer s.mux.Unlock() - _, ok := s.Groups[groupname] - if !ok { - return fmt.Errorf("group '%s' does not exist", groupname) - } + s.ensureGroupExistsNL(groupname) rule := s.getOrCreateRuleNL(sourcePath, indexPath) if _, ok := rule.Allow.Groups[groupname]; ok { return errors.ErrExist @@ -516,6 +540,8 @@ func (s *Storage) AddUserToGroup(group, username string) error { return nil } s.Groups[group][username] = struct{}{} + s.persistGroupSQLNL(group) + s.clearAllCaches() return nil } @@ -546,26 +572,30 @@ func (s *Storage) GetUserGroups(username string) []string { // SyncUserGroups updates a user's group memberships. // It removes the user from groups not in the new list and adds them to new ones. +// Changes are write-through to SQL when a sqlStore is configured. func (s *Storage) SyncUserGroups(username string, newGroups []string) error { s.mux.Lock() defer s.mux.Unlock() - changed := false + affected := make(map[string]struct{}) // Create a set of new groups for efficient lookup newGroupsSet := make(StringSet, len(newGroups)) for _, g := range newGroups { + if g == "" { + continue + } newGroupsSet[g] = struct{}{} } // Iterate over all existing groups to find the user's current memberships - for group, users := range s.Groups { - _, userIsInGroup := users[username] + for group, members := range s.Groups { + _, userIsInGroup := members[username] _, groupIsInNewSet := newGroupsSet[group] // If user is in a group that is not in their new set of groups, remove them. if userIsInGroup && !groupIsInNewSet { delete(s.Groups[group], username) - changed = true + affected[group] = struct{}{} } } @@ -576,11 +606,14 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error { } if _, ok := s.Groups[group][username]; !ok { s.Groups[group][username] = struct{}{} - changed = true + affected[group] = struct{}{} } } - if changed { - return nil + for group := range affected { + s.persistGroupSQLNL(group) + } + if len(affected) > 0 { + s.clearAllCaches() } return nil } @@ -589,17 +622,16 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error { func (s *Storage) RemoveUserFromGroup(group, username string) error { s.mux.Lock() defer s.mux.Unlock() - users, ok := s.Groups[group] + members, ok := s.Groups[group] if !ok { return nil } - if _, ok := users[username]; !ok { + if _, ok := members[username]; !ok { return nil } - delete(users, username) - if len(s.Groups[group]) == 0 { - delete(s.Groups, group) - } + delete(members, username) + s.persistGroupSQLNL(group) + s.clearAllCaches() return nil } diff --git a/backend/internal/database/access/access_test.go b/backend/internal/database/access/access_test.go index aecb4d7c..6d1bf165 100644 --- a/backend/internal/database/access/access_test.go +++ b/backend/internal/database/access/access_test.go @@ -6,12 +6,12 @@ import ( "strings" "testing" - "github.com/gtsteffaniak/filebrowser/backend/internal/errors" - "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" - "github.com/gtsteffaniak/filebrowser/backend/internal/utils" "github.com/gtsteffaniak/filebrowser/backend/internal/database/access" "github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb" "github.com/gtsteffaniak/filebrowser/backend/internal/database/users" + "github.com/gtsteffaniak/filebrowser/backend/internal/errors" + "github.com/gtsteffaniak/filebrowser/backend/internal/utils" + "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" ) func idxPath(s string) utils.IndexPath { @@ -62,7 +62,13 @@ func (s *sqlStoreAdapter) DeleteByID(id uint64) error { } func createTestStorage(t *testing.T) (*access.Storage, *users.Storage) { - // Create a temporary directory for the test database + t.Helper() + accessStore, userStore, _ := createTestStorageWithSQL(t) + return accessStore, userStore +} + +func createTestStorageWithSQL(t *testing.T) (*access.Storage, *users.Storage, *sqldb.SQLStore) { + t.Helper() tempDir, err := os.MkdirTemp("", "access_test_*") if err != nil { t.Fatalf("failed to create temp dir: %v", err) @@ -71,7 +77,6 @@ func createTestStorage(t *testing.T) (*access.Storage, *users.Storage) { os.RemoveAll(tempDir) }) - // Create an in-memory SQLite database for tests dbPath := filepath.Join(tempDir, "test.db") sqlStore, _, err := sqldb.NewSQLStore(dbPath) if err != nil { @@ -81,10 +86,11 @@ func createTestStorage(t *testing.T) (*access.Storage, *users.Storage) { sqlStore.Close() }) - // Wrap SQLStore with adapter adapter := &sqlStoreAdapter{store: sqlStore} userStore := users.NewStorage(adapter) - return access.NewStorage(userStore), userStore + accessStore := access.NewStorage(userStore) + accessStore.SetSQLStore(sqlStore) + return accessStore, userStore, sqlStore } func createTestUser(t *testing.T, userStore *users.Storage, username string) { @@ -209,6 +215,27 @@ func TestPermitted_NoRule(t *testing.T) { } } +func TestPermitted_trailingSlashEquivalence(t *testing.T) { + setupTestSources() + s, userStore := createTestStorage(t) + createTestUser(t, userStore, "alice") + if err := s.DenyUser("mnt/storage", idxPath("/secret/"), "alice"); err != nil { + t.Fatalf("DenyUser failed: %v", err) + } + + withSlash := s.Permitted("mnt/storage", idxPath("/secret/"), "alice") + withoutSlash := s.Permitted("mnt/storage", idxPath("/secret"), "alice") + if withSlash != withoutSlash { + t.Errorf("trailing slash mismatch: with=%v without=%v", withSlash, withoutSlash) + } + if withSlash { + t.Error("alice should be denied for /secret/ after DenyUser") + } + if withoutSlash { + t.Error("alice should be denied for /secret after DenyUser") + } +} + func TestPermitted_CombinedRules(t *testing.T) { setupTestSources() s, userStore := createTestStorage(t) @@ -1712,3 +1739,115 @@ func TestRemoveUserCascade_MixedUsers(t *testing.T) { t.Log("✓ Cascade delete only affects the specified user") } +func TestSyncUserGroups_WriteThroughAndReload(t *testing.T) { + setupTestSources() + s, _, sqlStore := createTestStorageWithSQL(t) + + if err := s.SyncUserGroups("alice", []string{"acme", "staff"}); err != nil { + t.Fatalf("SyncUserGroups: %v", err) + } + + groups, err := sqlStore.GetAllGroups() + if err != nil { + t.Fatalf("GetAllGroups: %v", err) + } + if _, ok := groups["acme"]["alice"]; !ok { + t.Fatalf("expected alice in acme in SQL, got %#v", groups) + } + if _, ok := groups["staff"]["alice"]; !ok { + t.Fatalf("expected alice in staff in SQL, got %#v", groups) + } + + // Simulate restart: new Storage loaded from SQL + reloaded := access.NewStorage(nil) + reloaded.Groups = groups + if got := reloaded.GetUserGroups("alice"); len(got) != 2 { + t.Fatalf("reloaded groups for alice: %v", got) + } + + // Sync to a single group and ensure SQL drops the old membership + if err = s.SyncUserGroups("alice", []string{"acme"}); err != nil { + t.Fatalf("SyncUserGroups update: %v", err) + } + groups, err = sqlStore.GetAllGroups() + if err != nil { + t.Fatalf("GetAllGroups after update: %v", err) + } + if _, ok := groups["acme"]["alice"]; !ok { + t.Fatal("alice should remain in acme") + } + if _, ok := groups["staff"]["alice"]; ok { + t.Fatal("alice should no longer be in staff in SQL") + } +} + +func TestAllowGroup_AutoCreatesMissingGroup(t *testing.T) { + setupTestSources() + s, _, sqlStore := createTestStorageWithSQL(t) + + if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil { + t.Fatalf("AllowGroup should auto-create group: %v", err) + } + groups := s.GetAllGroups() + found := false + for _, g := range groups { + if g == "acme" { + found = true + } + } + if !found { + t.Fatal("expected acme group in memory") + } + sqlGroups, err := sqlStore.GetAllGroups() + if err != nil { + t.Fatalf("GetAllGroups: %v", err) + } + if _, ok := sqlGroups["acme"]; !ok { + t.Fatal("expected acme group persisted to SQL") + } + + // After sync, user membership should permit access under denyByDefault + settings.Config.Server.SourceMap["mnt/storage"].Config.DenyByDefault = true + t.Cleanup(func() { + settings.Config.Server.SourceMap["mnt/storage"].Config.DenyByDefault = false + }) + if s.Permitted("mnt/storage", idxPath("/tenant"), "alice") { + t.Fatal("alice should be denied before group membership") + } + if err := s.SyncUserGroups("alice", []string{"acme"}); err != nil { + t.Fatalf("SyncUserGroups: %v", err) + } + if !s.Permitted("mnt/storage", idxPath("/tenant"), "alice") { + t.Fatal("alice should be permitted after JWT-style group sync") + } +} + +func TestAddUserToGroup_WriteThrough(t *testing.T) { + setupTestSources() + s, _, sqlStore := createTestStorageWithSQL(t) + + if err := s.AddUserToGroup("editors", "bob"); err != nil { + t.Fatalf("AddUserToGroup: %v", err) + } + groups, err := sqlStore.GetAllGroups() + if err != nil { + t.Fatalf("GetAllGroups: %v", err) + } + if _, ok := groups["editors"]["bob"]; !ok { + t.Fatalf("expected bob in editors in SQL, got %#v", groups) + } + + if err = s.RemoveUserFromGroup("editors", "bob"); err != nil { + t.Fatalf("RemoveUserFromGroup: %v", err) + } + groups, err = sqlStore.GetAllGroups() + if err != nil { + t.Fatalf("GetAllGroups after remove: %v", err) + } + if _, ok := groups["editors"]; !ok { + t.Fatal("expected editors group to remain after last member removed") + } + if _, still := groups["editors"]["bob"]; still { + t.Fatal("bob should be removed from editors in SQL") + } +} diff --git a/backend/internal/database/users/users.go b/backend/internal/database/users/users.go index cd374d8c..c22f96cd 100644 --- a/backend/internal/database/users/users.go +++ b/backend/internal/database/users/users.go @@ -310,6 +310,11 @@ func GetSourceConfig() *SourceConfigProvider { return sourceConfig } +// GetSourceNameResolver returns the current source name resolver, or nil if unset. +func GetSourceNameResolver() SourceNameResolver { + return sourceNameResolver +} + // SourceConfigLoaded reports whether SetSourceConfig has been called (needed for ResolveSourceKey). func SourceConfigLoaded() bool { return sourceConfig != nil diff --git a/backend/internal/errors/errors.go b/backend/internal/errors/errors.go index c38eed4f..46701c6f 100644 --- a/backend/internal/errors/errors.go +++ b/backend/internal/errors/errors.go @@ -23,6 +23,7 @@ var ( ErrUnauthorized = errors.New("user unauthorized") ErrNotIndexed = errors.New("directory or item excluded from indexing") ErrNotViewable = errors.New("directory or item is not viewable") + ErrPathEscapesScope = errors.New("resolved path escapes scope") ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method") ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share") ErrUseMediaStream = errors.New("use /media/stream for audio and video") diff --git a/backend/internal/ffmpeg/subtitles.go b/backend/internal/ffmpeg/subtitles.go index 9d2d6b77..eebc33a5 100644 --- a/backend/internal/ffmpeg/subtitles.go +++ b/backend/internal/ffmpeg/subtitles.go @@ -2,7 +2,9 @@ package ffmpeg import ( "context" + "fmt" "strconv" + "strings" "time" "github.com/gtsteffaniak/filebrowser/backend/internal/utils" @@ -35,24 +37,42 @@ func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.Subtit func mapSubtitleTracks(tracks []goffmpeg.SubtitleTrack) []utils.SubtitleTrack { subtitles := make([]utils.SubtitleTrack, 0, len(tracks)) + mapper := &subtitleTrackMapper{ + seenLang: make(map[string]int), + } for _, stream := range tracks { - index := stream.Index - track := utils.SubtitleTrack{ - Index: &index, - Codec: stream.Codec, - Language: stream.Language, - Title: stream.Title, - Embedded: true, - } - baseName := "Embedded Subtitle " + strconv.Itoa(stream.Index) - if track.Title != "" { - track.Name = baseName + " (" + track.Title + ")" - } else if track.Language != "" { - track.Name = baseName + " (" + track.Language + ")" - } else { - track.Name = baseName - } - subtitles = append(subtitles, track) + subtitles = append(subtitles, mapper.track(stream)) } return subtitles } + +type subtitleTrackMapper struct { + seenLang map[string]int +} + +func (m *subtitleTrackMapper) track(stream goffmpeg.SubtitleTrack) utils.SubtitleTrack { + index := stream.Index + lang := strings.TrimSpace(stream.Language) + return utils.SubtitleTrack{ + Index: &index, + Codec: stream.Codec, + Language: lang, + Srclang: m.srclang(lang, stream.Index), + Title: stream.Title, + Embedded: true, + Name: "Track " + strconv.Itoa(stream.Index), + } +} + +// srclang must be unique per stream so Plyr/HTML can switch tracks with the same Language. +func (m *subtitleTrackMapper) srclang(language string, streamIndex int) string { + lang := strings.TrimSpace(strings.ToLower(language)) + if lang != "" { + count := m.seenLang[lang] + m.seenLang[lang] = count + 1 + if count == 0 { + return lang + } + } + return fmt.Sprintf("x-track-%d", streamIndex) +} diff --git a/backend/internal/ffmpeg/subtitles_test.go b/backend/internal/ffmpeg/subtitles_test.go index 33b75fa9..1efca701 100644 --- a/backend/internal/ffmpeg/subtitles_test.go +++ b/backend/internal/ffmpeg/subtitles_test.go @@ -6,6 +6,38 @@ import ( goffmpeg "github.com/gtsteffaniak/go-ffmpeg" ) +func TestMapSubtitleTracks_uniqueUndefinedLanguage(t *testing.T) { + tracks := mapSubtitleTracks([]goffmpeg.SubtitleTrack{ + {Index: 2}, + {Index: 3}, + }) + if len(tracks) != 2 { + t.Fatalf("len(tracks) = %d, want 2", len(tracks)) + } + if tracks[0].Name != "Track 2" { + t.Fatalf("tracks[0].Name = %q, want Track 2", tracks[0].Name) + } + if tracks[1].Name != "Track 3" { + t.Fatalf("tracks[1].Name = %q, want Track 3", tracks[1].Name) + } +} + +func TestMapSubtitleTracks_duplicateLanguage(t *testing.T) { + tracks := mapSubtitleTracks([]goffmpeg.SubtitleTrack{ + {Index: 2, Language: "chi", Title: "Simplified Chinese"}, + {Index: 3, Language: "chi", Title: "Traditional Chinese"}, + }) + if len(tracks) != 2 { + t.Fatalf("len(tracks) = %d, want 2", len(tracks)) + } + if tracks[0].Name != "Track 2" { + t.Fatalf("tracks[0].Name = %q, want Track 2", tracks[0].Name) + } + if tracks[1].Name != "Track 3" { + t.Fatalf("tracks[1].Name = %q, want Track 3", tracks[1].Name) + } +} + func TestMapSubtitleTracks_uniqueNames(t *testing.T) { tracks := mapSubtitleTracks([]goffmpeg.SubtitleTrack{ {Index: 0, Title: "English", Language: "eng"}, @@ -17,11 +49,72 @@ func TestMapSubtitleTracks_uniqueNames(t *testing.T) { if tracks[0].Name == tracks[1].Name { t.Fatalf("duplicate subtitle names: %q", tracks[0].Name) } - if tracks[0].Index == nil || *tracks[0].Index != 0 { - t.Fatalf("tracks[0].Index = %v, want 0", tracks[0].Index) + if tracks[0].Name != "Track 0" { + t.Fatalf("tracks[0].Name = %q, want Track 0", tracks[0].Name) } - if tracks[1].Index == nil || *tracks[1].Index != 1 { - t.Fatalf("tracks[1].Index = %v, want 1", tracks[1].Index) + if tracks[1].Name != "Track 1" { + t.Fatalf("tracks[1].Name = %q, want Track 1", tracks[1].Name) + } +} + +func TestMapSubtitleTracks_duplicateLanguageNoTitle(t *testing.T) { + tracks := mapSubtitleTracks([]goffmpeg.SubtitleTrack{ + {Index: 2, Language: "eng"}, + {Index: 3, Language: "eng"}, + }) + if len(tracks) != 2 { + t.Fatalf("len(tracks) = %d, want 2", len(tracks)) + } + if tracks[0].Name != "Track 2" { + t.Fatalf("tracks[0].Name = %q, want Track 2", tracks[0].Name) + } + if tracks[1].Name != "Track 3" { + t.Fatalf("tracks[1].Name = %q, want Track 3", tracks[1].Name) + } +} + +func TestMapSubtitleTracks_mixedEngAndUndefined(t *testing.T) { + tracks := mapSubtitleTracks([]goffmpeg.SubtitleTrack{ + {Index: 2, Language: "eng"}, + {Index: 3}, + {Index: 4}, + }) + if len(tracks) != 3 { + t.Fatalf("len(tracks) = %d, want 3", len(tracks)) + } + if tracks[0].Name != "Track 2" { + t.Fatalf("tracks[0].Name = %q", tracks[0].Name) + } + if tracks[1].Name != "Track 3" { + t.Fatalf("tracks[1].Name = %q", tracks[1].Name) + } + if tracks[2].Name != "Track 4" { + t.Fatalf("tracks[2].Name = %q", tracks[2].Name) + } +} + +func TestMapSubtitleTracks_multiLanguageMenu(t *testing.T) { + tracks := mapSubtitleTracks([]goffmpeg.SubtitleTrack{ + {Index: 4, Language: "fra"}, + {Index: 5, Language: "ger"}, + {Index: 6, Language: "ger"}, + {Index: 7, Language: "eng"}, + {Index: 8, Language: "eng"}, + }) + wantNames := []string{"Track 4", "Track 5", "Track 6", "Track 7", "Track 8"} + if len(tracks) != len(wantNames) { + t.Fatalf("len(tracks) = %d, want %d", len(tracks), len(wantNames)) + } + for i, name := range wantNames { + if tracks[i].Name != name { + t.Fatalf("tracks[%d].Name = %q, want %q", i, tracks[i].Name, name) + } + } + wantSrclang := []string{"fra", "ger", "x-track-6", "eng", "x-track-8"} + for i, srclang := range wantSrclang { + if tracks[i].Srclang != srclang { + t.Fatalf("tracks[%d].Srclang = %q, want %q", i, tracks[i].Srclang, srclang) + } } } @@ -34,17 +127,17 @@ func TestMapSubtitleTracks_nameFormats(t *testing.T) { { name: "title only", stream: goffmpeg.SubtitleTrack{Index: 3, Title: "Director's Commentary"}, - wantName: "Embedded Subtitle 3 (Director's Commentary)", + wantName: "Track 3", }, { name: "language only", stream: goffmpeg.SubtitleTrack{Index: 5, Language: "deu"}, - wantName: "Embedded Subtitle 5 (deu)", + wantName: "Track 5", }, { name: "no title or language", stream: goffmpeg.SubtitleTrack{Index: 7}, - wantName: "Embedded Subtitle 7", + wantName: "Track 7", }, } diff --git a/backend/internal/imagemeta/embedded.go b/backend/internal/imagemeta/embedded.go index a26e8908..7a5cb620 100644 --- a/backend/internal/imagemeta/embedded.go +++ b/backend/internal/imagemeta/embedded.go @@ -81,19 +81,11 @@ func extractTIFFEmbeddedPreview(f *os.File) ([]byte, error) { return nil, err } - for _, c := range previewCandidates(ex) { - if c.length == 0 { - continue - } - data, readErr := readFileRange(f, c.offset, c.length) - if readErr != nil || len(data) == 0 { - continue - } - if IsJPEG(data) { - return data, nil - } + candidates := previewCandidates(ex) + if scanCandidates, scanErr := collectTIFFPreviewCandidates(f); scanErr == nil { + candidates = append(candidates, scanCandidates...) } - return nil, nil + return readLargestJPEGPreview(f, candidates) } type previewCandidate struct { @@ -140,6 +132,26 @@ func previewCandidates(ex exif.Exif) []previewCandidate { return out } +func readLargestJPEGPreview(f *os.File, candidates []previewCandidate) ([]byte, error) { + var best []byte + for _, c := range candidates { + if c.length == 0 { + continue + } + data, err := readFileRange(f, c.offset, c.length) + if err != nil || len(data) == 0 || !IsJPEG(data) { + continue + } + if len(data) > len(best) { + best = data + } + } + if len(best) == 0 { + return nil, nil + } + return best, nil +} + const maxPreviewReadSize = 100 * 1024 * 1024 // embedded previews are never larger func readFileRange(f *os.File, offset, length uint32) ([]byte, error) { diff --git a/backend/internal/imagemeta/heic_transform.go b/backend/internal/imagemeta/heic_transform.go new file mode 100644 index 00000000..7677d74d --- /dev/null +++ b/backend/internal/imagemeta/heic_transform.go @@ -0,0 +1,272 @@ +//go:build !386 && !arm + +package imagemeta + +import ( + "encoding/binary" + "io" + "os" +) + +const heicMetadataScanLimit = 32 * 1024 * 1024 + +type heicTransform struct { + irot uint8 + found bool +} + +// parseHEICTransform reads the irot property associated with the primary image item. +// Per MIAF, irot takes precedence over informational EXIF Orientation (same as +// ExifTool QuickTime:Rotation, which the previous exiftool integration used). +func parseHEICTransform(f *os.File) heicTransform { + info, err := f.Stat() + if err != nil { + return heicTransform{} + } + size := info.Size() + if size > heicMetadataScanLimit { + size = heicMetadataScanLimit + } + if size < 12 { + return heicTransform{} + } + buf := make([]byte, size) + n, err := f.ReadAt(buf, 0) + if err != nil && err != io.EOF { + return heicTransform{} + } + return parseHEICPrimaryIrot(buf[:n]) +} + +func parseHEICPrimaryIrot(data []byte) heicTransform { + meta := findBMFFBox(data, "meta") + if meta == nil { + return heicTransform{} + } + + primaryID := parsePITMItemID(findBMFFChildBox(meta, "pitm")) + if primaryID == 0 { + return heicTransform{} + } + + iprp := findBMFFChildBox(meta, "iprp") + if iprp == nil { + return heicTransform{} + } + + ipco := findBMFFChildBox(iprp, "ipco") + ipma := findBMFFChildBox(iprp, "ipma") + if ipco == nil || ipma == nil { + return heicTransform{} + } + + properties := parseIPCOProperties(ipco) + indices := parseIPMAPropertyIndices(ipma, primaryID) + for _, idx := range indices { + if idx == 0 || int(idx) > len(properties) { + continue + } + prop := properties[idx-1] + if prop.typ == "irot" { + return heicTransform{irot: prop.irot & 0x03, found: true} + } + } + return heicTransform{} +} + +func findBMFFBox(data []byte, typ string) []byte { + return findBMFFBoxRange(data, 0, len(data), typ) +} + +func findBMFFBoxRange(data []byte, start, end int, typ string) []byte { + pos := start + for pos+8 <= end { + size, header, ok := bmffBoxHeader(data, pos, end) + if !ok || size < header { + break + } + boxEnd := pos + size + if boxEnd > end { + break + } + if string(data[pos+4:pos+8]) == typ { + return data[pos:boxEnd] + } + if isBMFFContainerType(data[pos+4 : pos+8]) { + if child := findBMFFBoxRange(data, pos+header, boxEnd, typ); child != nil { + return child + } + } + pos = boxEnd + } + return nil +} + +func findBMFFChildBox(container []byte, typ string) []byte { + payload := bmffBoxPayload(container) + if payload == nil { + return nil + } + return findBMFFBoxRange(payload, 0, len(payload), typ) +} + +func isBMFFContainerType(typ []byte) bool { + switch string(typ) { + case "meta", "iprp", "ipco", "moov", "trak", "mdia", "minf", "stbl", "dinf": + return true + default: + return false + } +} + +func bmffBoxHeader(data []byte, pos, end int) (size int, header int, ok bool) { + if pos+8 > end { + return 0, 0, false + } + size32 := binary.BigEndian.Uint32(data[pos : pos+4]) + if size32 == 0 { + return 0, 0, false + } + if size32 == 1 { + if pos+16 > end { + return 0, 0, false + } + return int(binary.BigEndian.Uint64(data[pos+8 : pos+16])), 16, true + } + return int(size32), 8, true +} + +func bmffBoxPayload(box []byte) []byte { + if len(box) < 8 { + return nil + } + typ := string(box[4:8]) + header := 8 + if binary.BigEndian.Uint32(box[0:4]) == 1 { + header = 16 + } + if isBMFFFullBoxType(typ) { + header += 4 + } + if header > len(box) { + return nil + } + return box[header:] +} + +func isBMFFFullBoxType(typ string) bool { + switch typ { + case "meta", "pitm", "irot", "imir", "ispe", "ipma", "hdlr", "iloc", "iinf", "infe": + return true + default: + return false + } +} + +func parsePITMItemID(pitm []byte) uint32 { + payload := bmffBoxPayload(pitm) + if len(pitm) < 12 || payload == nil { + return 0 + } + version := pitm[8] + switch version { + case 0: + if len(payload) < 2 { + return 0 + } + return uint32(binary.BigEndian.Uint16(payload[0:2])) + default: + if len(payload) < 4 { + return 0 + } + return binary.BigEndian.Uint32(payload[0:4]) + } +} + +type ipcoProperty struct { + typ string + irot uint8 +} + +func parseIPCOProperties(ipco []byte) []ipcoProperty { + payload := bmffBoxPayload(ipco) + if payload == nil { + return nil + } + var props []ipcoProperty + pos := 0 + for pos+8 <= len(payload) { + size, header, ok := bmffBoxHeader(payload, pos, len(payload)) + if !ok || size < header || pos+size > len(payload) { + break + } + child := payload[pos : pos+size] + prop := ipcoProperty{typ: string(child[4:8])} + if prop.typ == "irot" && len(child) >= 13 { + prop.irot = child[12] & 0x03 + } + props = append(props, prop) + pos += size + } + return props +} + +func parseIPMAPropertyIndices(ipma []byte, itemID uint32) []uint8 { + payload := bmffBoxPayload(ipma) + if len(ipma) < 12 || payload == nil || len(payload) < 4 { + return nil + } + version := ipma[8] + entryCount := int(binary.BigEndian.Uint32(payload[0:4])) + pos := 4 + for i := 0; i < entryCount && pos < len(payload); i++ { + var id uint32 + switch version { + case 0: + if pos+2 > len(payload) { + return nil + } + id = uint32(binary.BigEndian.Uint16(payload[pos : pos+2])) + pos += 2 + default: + if pos+4 > len(payload) { + return nil + } + id = binary.BigEndian.Uint32(payload[pos : pos+4]) + pos += 4 + } + if pos >= len(payload) { + return nil + } + assocCount := int(payload[pos]) + pos++ + indices := make([]uint8, 0, assocCount) + for j := 0; j < assocCount && pos < len(payload); j++ { + indices = append(indices, payload[pos]&0x7f) + pos++ + } + if id == itemID { + return indices + } + } + return nil +} + +// heicTransformOrientation maps irot to EXIF-style orientation labels used elsewhere. +func heicTransformOrientation(t heicTransform) string { + if !t.found { + return "" + } + switch t.irot & 0x03 { + case 0: + return "Horizontal (normal)" + case 1: + return "Rotate 270 CW" + case 2: + return "Rotate 180" + case 3: + return "Rotate 90 CW" + default: + return "" + } +} diff --git a/backend/internal/imagemeta/heic_transform_test.go b/backend/internal/imagemeta/heic_transform_test.go new file mode 100644 index 00000000..1db883bc --- /dev/null +++ b/backend/internal/imagemeta/heic_transform_test.go @@ -0,0 +1,253 @@ +//go:build !386 && !arm + +package imagemeta + +import ( + "encoding/binary" + "os" + "path/filepath" + "testing" +) + +func TestHeicTransformOrientationMapping(t *testing.T) { + tests := []struct { + name string + t heicTransform + want string + }{ + { + name: "irot normal", + t: heicTransform{irot: 0, found: true}, + want: "Horizontal (normal)", + }, + { + name: "irot 90 cw", + t: heicTransform{irot: 3, found: true}, + want: "Rotate 90 CW", + }, + { + name: "missing", + t: heicTransform{}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := heicTransformOrientation(tt.t); got != tt.want { + t.Fatalf("heicTransformOrientation() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseHEICPrimaryIrotUsesPrimaryItem(t *testing.T) { + data := buildSyntheticHEIC(heicFixture{ + primaryItemID: 1, + primaryIrot: 0, + thumbnailItem: &heicItemFixture{itemID: 2, irot: 3}, + decoyIrotInMDAT: true, + }) + got := parseHEICPrimaryIrot(data) + if !got.found || got.irot != 0 { + t.Fatalf("parseHEICPrimaryIrot() = %+v, want irot=0 from primary item", got) + } +} + +func TestParseHEICPrimaryIrotRotate90(t *testing.T) { + data := buildSyntheticHEIC(heicFixture{ + primaryItemID: 1, + primaryIrot: 3, + }) + got := parseHEICPrimaryIrot(data) + if !got.found || got.irot != 3 { + t.Fatalf("parseHEICPrimaryIrot() = %+v, want irot=3", got) + } +} + +func TestParseHEICPrimaryIrotMissingPITM(t *testing.T) { + data := buildSyntheticHEIC(heicFixture{ + omitPITM: true, + primaryItemID: 1, + primaryIrot: 0, + }) + got := parseHEICPrimaryIrot(data) + if got.found { + t.Fatalf("parseHEICPrimaryIrot() = %+v, want not found without pitm", got) + } +} + +func TestGetOrientationSyntheticHEIC(t *testing.T) { + path := writeTempHEIC(t, buildSyntheticHEIC(heicFixture{ + primaryItemID: 1, + primaryIrot: 0, + thumbnailItem: &heicItemFixture{itemID: 2, irot: 3}, + })) + if got := GetOrientation(t.Context(), path); got != "Horizontal (normal)" { + t.Fatalf("GetOrientation() = %q, want Horizontal (normal)", got) + } +} + +func TestGetOrientationSyntheticHEICRotate(t *testing.T) { + path := writeTempHEIC(t, buildSyntheticHEIC(heicFixture{ + primaryItemID: 1, + primaryIrot: 3, + })) + if got := GetOrientation(t.Context(), path); got != "Rotate 90 CW" { + t.Fatalf("GetOrientation() = %q, want Rotate 90 CW", got) + } +} + +type heicItemFixture struct { + itemID uint16 + irot uint8 +} + +type heicFixture struct { + primaryItemID uint16 + primaryIrot uint8 + thumbnailItem *heicItemFixture + omitPITM bool + decoyIrotInMDAT bool +} + +func writeTempHEIC(t *testing.T, data []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "sample.heic") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func buildSyntheticHEIC(fix heicFixture) []byte { + var properties []byte + propertyIndex := uint8(1) + + appendProperty := func(box []byte) uint8 { + idx := propertyIndex + properties = append(properties, box...) + propertyIndex++ + return idx + } + + primaryIspe := appendProperty(bmffFullBox("ispe", 0, []byte{0, 0, 1, 0, 0, 0, 2, 0})) + primaryIrot := appendProperty(bmffIrotBox(fix.primaryIrot)) + + var ipmaEntries []ipmaEntryFixture + primaryAssoc := []ipmaAssocFixture{ + {index: primaryIspe, essential: true}, + {index: primaryIrot, essential: false}, + } + ipmaEntries = append(ipmaEntries, ipmaEntryFixture{ + itemID: fix.primaryItemID, + assoc: primaryAssoc, + }) + + if fix.thumbnailItem != nil { + thumbIspe := appendProperty(bmffFullBox("ispe", 0, []byte{0, 0, 0, 64, 0, 0, 0, 64})) + thumbIrot := appendProperty(bmffIrotBox(fix.thumbnailItem.irot)) + ipmaEntries = append(ipmaEntries, ipmaEntryFixture{ + itemID: fix.thumbnailItem.itemID, + assoc: []ipmaAssocFixture{ + {index: thumbIspe, essential: true}, + {index: thumbIrot, essential: false}, + }, + }) + } + + ipco := bmffBox("ipco", properties) + ipma := bmffIPMABox(ipmaEntries) + iprp := bmffBox("iprp", append(ipco, ipma...)) + + var metaChildren []byte + if !fix.omitPITM { + metaChildren = append(metaChildren, bmffPITMBox(fix.primaryItemID)...) + } + metaChildren = append(metaChildren, iprp...) + meta := bmffFullBox("meta", 0, metaChildren) + + out := []byte{} + out = append(out, bmffFtypBox()...) + out = append(out, meta...) + if fix.decoyIrotInMDAT { + decoy := make([]byte, 64) + copy(decoy[4:8], []byte("irot")) + decoy[8] = 3 + out = append(out, bmffBox("mdat", decoy)...) + } + return out +} + +type ipmaAssocFixture struct { + index uint8 + essential bool +} + +type ipmaEntryFixture struct { + itemID uint16 + assoc []ipmaAssocFixture +} + +func bmffBox(typ string, payload []byte) []byte { + size := 8 + len(payload) + out := make([]byte, size) + binary.BigEndian.PutUint32(out[0:4], uint32(size)) + copy(out[4:8], typ) + copy(out[8:], payload) + return out +} + +func bmffFullBox(typ string, version byte, payload []byte) []byte { + size := 12 + len(payload) + out := make([]byte, size) + binary.BigEndian.PutUint32(out[0:4], uint32(size)) + copy(out[4:8], typ) + out[8] = version + copy(out[12:], payload) + return out +} + +func bmffFtypBox() []byte { + payload := append([]byte("heic"), 0, 0, 0, 0) + payload = append(payload, []byte("mif1")...) + return bmffBox("ftyp", payload) +} + +func bmffPITMBox(itemID uint16) []byte { + payload := make([]byte, 2) + binary.BigEndian.PutUint16(payload, itemID) + return bmffFullBox("pitm", 0, payload) +} + +func bmffIrotBox(angle uint8) []byte { + return bmffFullBox("irot", 0, []byte{angle & 0x03}) +} + +func bmffIPMABox(entries []ipmaEntryFixture) []byte { + var payload []byte + payload = appendUint32BE(payload, uint32(len(entries))) + for _, entry := range entries { + payload = appendUint16BE(payload, entry.itemID) + payload = append(payload, byte(len(entry.assoc))) + for _, assoc := range entry.assoc { + value := assoc.index & 0x7f + if assoc.essential { + value |= 0x80 + } + payload = append(payload, value) + } + } + return bmffFullBox("ipma", 0, payload) +} + +func appendUint32BE(dst []byte, v uint32) []byte { + var b [4]byte + binary.BigEndian.PutUint32(b[:], v) + return append(dst, b[:]...) +} + +func appendUint16BE(dst []byte, v uint16) []byte { + var b [2]byte + binary.BigEndian.PutUint16(b[:], v) + return append(dst, b[:]...) +} diff --git a/backend/internal/imagemeta/orientation.go b/backend/internal/imagemeta/orientation.go index 102e38ac..16a7b682 100644 --- a/backend/internal/imagemeta/orientation.go +++ b/backend/internal/imagemeta/orientation.go @@ -4,6 +4,7 @@ package imagemeta import ( "context" + "io" "os" "path/filepath" "strings" @@ -27,9 +28,21 @@ func GetOrientation(ctx context.Context, path string) string { defer f.Close() ext := strings.ToLower(filepath.Ext(path)) + if isHEICExtension(ext) { + if _, err = f.Seek(0, io.SeekStart); err != nil { + return "" + } + if orient := heicTransformOrientation(parseHEICTransform(f)); orient != "" { + return orient + } + } + var ex exif.Exif switch ext { case ".heic", ".heif", ".heics": + if _, err = f.Seek(0, io.SeekStart); err != nil { + return "" + } ex, err = extimagemeta.DecodeHeif(f) default: ex, err = extimagemeta.Decode(f) diff --git a/backend/internal/imagemeta/tiff_preview.go b/backend/internal/imagemeta/tiff_preview.go new file mode 100644 index 00000000..ebf9593f --- /dev/null +++ b/backend/internal/imagemeta/tiff_preview.go @@ -0,0 +1,248 @@ +//go:build !386 && !arm + +package imagemeta + +import ( + "encoding/binary" + "io" + "os" +) + +const ( + tiffTagSubIFDs = 0x014a + tiffTagJpgFromRaw = 0x002e + tiffTagJPEGInterchange = 0x0201 + tiffTagJPEGInterchangeLen = 0x0202 + tiffIFDScanLimit = 4 * 1024 * 1024 + tiffTypeByte = 1 + tiffTypeASCII = 2 + tiffTypeShort = 3 + tiffTypeLong = 4 + tiffTypeUndefined = 7 + maxTIFFIFDsVisited = 128 + maxTIFFIFDQueueLen = 128 + maxTIFFSubIFDsPerEntry = 16 + maxTIFFPreviewCandidates = 32 +) + +type tiffIFD struct { + tags map[uint16]tiffTag +} + +type tiffTag struct { + typ uint16 + count uint32 + valueOffset uint32 +} + +// collectTIFFPreviewCandidates walks TIFF IFDs (including SubIFDs) for embedded +// JPEG preview offset/length pairs that imagemeta does not surface. +func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) { + info, err := f.Stat() + if err != nil { + return nil, err + } + scanSize := info.Size() + if scanSize > tiffIFDScanLimit { + scanSize = tiffIFDScanLimit + } + if scanSize < 8 { + return nil, nil + } + header := make([]byte, scanSize) + n, err := f.ReadAt(header, 0) + if err != nil && err != io.EOF { + return nil, err + } + header = header[:n] + + order, ifd0, ok := tiffHeaderIFD0(header) + if !ok { + return nil, nil + } + + seen := make(map[uint32]struct{}) + var ifds []tiffIFD + var queue []uint32 + queue = append(queue, ifd0) + for len(queue) > 0 && len(seen) < maxTIFFIFDsVisited { + off := queue[0] + queue = queue[1:] + if off == 0 { + continue + } + if _, done := seen[off]; done { + continue + } + seen[off] = struct{}{} + + ifd, next, subIFDs, err := parseTIFFIFD(header, order, off) + if err != nil { + continue + } + ifds = append(ifds, ifd) + if next != 0 && len(queue) < maxTIFFIFDQueueLen { + queue = append(queue, next) + } + for _, sub := range subIFDs { + if len(queue) >= maxTIFFIFDQueueLen { + break + } + queue = append(queue, sub) + } + } + + var out []previewCandidate + for _, ifd := range ifds { + out = appendPreviewCandidates(out, jpgFromRawCandidates(ifd)) + out = appendPreviewCandidates(out, jpegInterchangeCandidates(ifd)) + if len(out) >= maxTIFFPreviewCandidates { + break + } + } + return out, nil +} + +func appendPreviewCandidates(dst, src []previewCandidate) []previewCandidate { + remaining := maxTIFFPreviewCandidates - len(dst) + if remaining <= 0 { + return dst + } + if len(src) > remaining { + src = src[:remaining] + } + return append(dst, src...) +} + +func jpgFromRawCandidates(ifd tiffIFD) []previewCandidate { + tag, ok := ifd.tags[tiffTagJpgFromRaw] + if !ok || tag.count == 0 { + return nil + } + byteLen := uint32(int(tag.count) * tiffTagByteSize(tag.typ)) + if byteLen <= 4 { + return nil + } + return []previewCandidate{{offset: tag.valueOffset, length: byteLen}} +} + +func jpegInterchangeCandidates(ifd tiffIFD) []previewCandidate { + offTag, okOff := ifd.tags[tiffTagJPEGInterchange] + lenTag, okLen := ifd.tags[tiffTagJPEGInterchangeLen] + if !okOff || !okLen || offTag.count == 0 || lenTag.count == 0 { + return nil + } + if offTag.typ != tiffTypeLong || lenTag.typ != tiffTypeLong { + return nil + } + offset := offTag.valueOffset + length := lenTag.valueOffset + if length == 0 || length > maxPreviewReadSize { + return nil + } + return []previewCandidate{{offset: offset, length: length}} +} + +func tiffHeaderIFD0(data []byte) (binary.ByteOrder, uint32, bool) { + if len(data) < 8 { + return nil, 0, false + } + var order binary.ByteOrder + switch string(data[:2]) { + case "II": + order = binary.LittleEndian + case "MM": + order = binary.BigEndian + default: + return nil, 0, false + } + if order.Uint16(data[2:4]) != 42 { + return nil, 0, false + } + return order, order.Uint32(data[4:8]), true +} + +func parseTIFFIFD(data []byte, order binary.ByteOrder, offset uint32) (ifd tiffIFD, next uint32, subIFDs []uint32, err error) { + ifd.tags = make(map[uint16]tiffTag) + off := int(offset) + if off < 0 || off+2 > len(data) { + return ifd, 0, nil, io.ErrUnexpectedEOF + } + count := int(order.Uint16(data[off : off+2])) + entryBase := off + 2 + for i := 0; i < count; i++ { + entry := entryBase + i*12 + if entry+12 > len(data) { + return ifd, 0, nil, io.ErrUnexpectedEOF + } + tagID := order.Uint16(data[entry : entry+2]) + typ := order.Uint16(data[entry+2 : entry+4]) + cnt := order.Uint32(data[entry+4 : entry+8]) + val := order.Uint32(data[entry+8 : entry+12]) + ifd.tags[tagID] = tiffTag{typ: typ, count: cnt, valueOffset: val} + if tagID == tiffTagSubIFDs { + subIFDs = limitSubIFDs(tiffTagUint32s(data, order, typ, cnt, val)) + } + } + nextOff := entryBase + count*12 + if nextOff+4 <= len(data) { + next = order.Uint32(data[nextOff : nextOff+4]) + } + return ifd, next, subIFDs, nil +} + +func limitSubIFDs(offsets []uint32) []uint32 { + if len(offsets) > maxTIFFSubIFDsPerEntry { + return offsets[:maxTIFFSubIFDsPerEntry] + } + return offsets +} + +func tiffTagUint32s(data []byte, order binary.ByteOrder, typ uint16, count uint32, valueOffset uint32) []uint32 { + if count == 0 { + return nil + } + if count > maxTIFFSubIFDsPerEntry { + count = maxTIFFSubIFDsPerEntry + } + var raw []byte + switch { + case typ == tiffTypeLong && count == 1: + var b [4]byte + order.PutUint32(b[:], valueOffset) + raw = b[:] + case tiffTagByteSize(typ)*int(count) <= 4: + var b [4]byte + order.PutUint32(b[:], valueOffset) + raw = b[:count*uint32(tiffTagByteSize(typ))] + default: + off := int(valueOffset) + n := int(count) * tiffTagByteSize(typ) + if off < 0 || off+n > len(data) { + return nil + } + raw = data[off : off+n] + } + out := make([]uint32, 0, count) + for i := 0; i < int(count); i++ { + pos := i * 4 + if pos+4 > len(raw) { + break + } + out = append(out, order.Uint32(raw[pos:pos+4])) + } + return out +} + +func tiffTagByteSize(typ uint16) int { + switch typ { + case tiffTypeByte, tiffTypeASCII, tiffTypeUndefined: + return 1 + case tiffTypeShort: + return 2 + case tiffTypeLong: + return 4 + default: + return 1 + } +} diff --git a/backend/internal/imagemeta/tiff_preview_test.go b/backend/internal/imagemeta/tiff_preview_test.go new file mode 100644 index 00000000..b827db53 --- /dev/null +++ b/backend/internal/imagemeta/tiff_preview_test.go @@ -0,0 +1,195 @@ +//go:build !386 && !arm + +package imagemeta + +import ( + "bytes" + "encoding/binary" + "os" + "path/filepath" + "testing" +) + +var ( + smallJPEG = []byte{0xff, 0xd8, 0xff, 0xd9} + largeJPEG = bytes.Repeat([]byte{0xff, 0xd8, 0xff, 0x00}, 512) +) + +func TestCollectTIFFPreviewCandidatesPrefersSubIFDLargePreview(t *testing.T) { + data := buildSyntheticTIFF(tiffFixture{ + thumbnailJPEG: smallJPEG, + subIFDJPEG: largeJPEG, + }) + f := openBytesFile(t, data) + + candidates, err := collectTIFFPreviewCandidates(f) + if err != nil { + t.Fatal(err) + } + preview, err := readLargestJPEGPreview(f, candidates) + if err != nil { + t.Fatal(err) + } + if !IsJPEG(preview) { + t.Fatalf("expected JPEG preview, got %d bytes", len(preview)) + } + if len(preview) != len(largeJPEG) { + t.Fatalf("preview size = %d, want %d (largest candidate)", len(preview), len(largeJPEG)) + } +} + +func TestCollectTIFFPreviewCandidatesIgnoresMalformedOffsets(t *testing.T) { + data := buildSyntheticTIFF(tiffFixture{ + thumbnailJPEG: smallJPEG, + subIFDJPEG: largeJPEG, + badJPEGInterchg: true, + }) + f := openBytesFile(t, data) + + candidates, err := collectTIFFPreviewCandidates(f) + if err != nil { + t.Fatal(err) + } + preview, err := readLargestJPEGPreview(f, candidates) + if err != nil { + t.Fatal(err) + } + if len(preview) != len(largeJPEG) { + t.Fatalf("preview size = %d, want %d", len(preview), len(largeJPEG)) + } +} + +func TestCollectTIFFPreviewCandidatesIgnoresMalformedSubIFDOffset(t *testing.T) { + data := buildSyntheticTIFF(tiffFixture{ + thumbnailJPEG: smallJPEG, + subIFDJPEG: largeJPEG, + badJpgFromRaw: true, + }) + f := openBytesFile(t, data) + + candidates, err := collectTIFFPreviewCandidates(f) + if err != nil { + t.Fatal(err) + } + preview, err := readLargestJPEGPreview(f, candidates) + if err != nil { + t.Fatal(err) + } + if len(preview) != len(smallJPEG) { + t.Fatalf("preview size = %d, want %d", len(preview), len(smallJPEG)) + } +} + +func TestCollectTIFFPreviewCandidatesBoundsIFDTraversal(t *testing.T) { + data := buildChainedTIFF(maxTIFFIFDsVisited + 16) + f := openBytesFile(t, data) + + candidates, err := collectTIFFPreviewCandidates(f) + if err != nil { + t.Fatal(err) + } + if len(candidates) > maxTIFFPreviewCandidates { + t.Fatalf("candidate count = %d, want <= %d", len(candidates), maxTIFFPreviewCandidates) + } +} + +type tiffFixture struct { + thumbnailJPEG []byte + subIFDJPEG []byte + badJpgFromRaw bool + badJPEGInterchg bool +} + +func openBytesFile(t *testing.T, data []byte) *os.File { + t.Helper() + path := filepath.Join(t.TempDir(), "sample.tiff") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = f.Close() }) + return f +} + +func buildSyntheticTIFF(fix tiffFixture) []byte { + order := binary.LittleEndian + buf := make([]byte, 64*1024) + + thumbOff := uint32(4096) + copy(buf[thumbOff:], fix.thumbnailJPEG) + largeOff := uint32(8192) + copy(buf[largeOff:], fix.subIFDJPEG) + + ifd1Off := uint32(256) + ifd0Off := uint32(8) + + writeIFD := func(offset uint32, entries []tiffEntry, next uint32) { + off := int(offset) + order.PutUint16(buf[off:off+2], uint16(len(entries))) + entryBase := off + 2 + for i, entry := range entries { + pos := entryBase + i*12 + order.PutUint16(buf[pos:pos+2], entry.tag) + order.PutUint16(buf[pos+2:pos+4], entry.typ) + order.PutUint32(buf[pos+4:pos+8], entry.count) + order.PutUint32(buf[pos+8:pos+12], entry.value) + } + order.PutUint32(buf[entryBase+len(entries)*12:entryBase+len(entries)*12+4], next) + } + + ifd1Entries := []tiffEntry{ + {tag: tiffTagJpgFromRaw, typ: tiffTypeUndefined, count: uint32(len(fix.subIFDJPEG)), value: largeOff}, + } + if fix.badJpgFromRaw { + ifd1Entries[0].value = 1_000_000 + } + writeIFD(ifd1Off, ifd1Entries, 0) + + ifd0Entries := []tiffEntry{ + {tag: tiffTagSubIFDs, typ: tiffTypeLong, count: 1, value: ifd1Off}, + {tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff}, + {tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(fix.thumbnailJPEG))}, + } + if fix.badJPEGInterchg { + ifd0Entries[1].value = 2_000_000 + } + writeIFD(ifd0Off, ifd0Entries, 0) + + copy(buf[0:2], []byte("II")) + order.PutUint16(buf[2:4], 42) + order.PutUint32(buf[4:8], ifd0Off) + + end := int(largeOff) + len(fix.subIFDJPEG) + if end < 1024 { + end = 1024 + } + return buf[:end] +} + +type tiffEntry struct { + tag, typ uint16 + count, value uint32 +} + +func buildChainedTIFF(chainLen int) []byte { + order := binary.LittleEndian + const ifdSize = 6 // 2-byte entry count + 4-byte next IFD pointer + buf := make([]byte, 8+chainLen*ifdSize) + copy(buf[0:2], []byte("II")) + order.PutUint16(buf[2:4], 42) + order.PutUint32(buf[4:8], 8) + + for i := 0; i < chainLen; i++ { + off := 8 + i*ifdSize + order.PutUint16(buf[off:off+2], 0) + var next uint32 + if i+1 < chainLen { + next = uint32(8 + (i+1)*ifdSize) + } + order.PutUint32(buf[off+2:off+6], next) + } + return buf +} diff --git a/backend/internal/preview/.gitignore b/backend/internal/preview/.gitignore new file mode 100644 index 00000000..2706cbbd --- /dev/null +++ b/backend/internal/preview/.gitignore @@ -0,0 +1 @@ +gofitzinclude diff --git a/backend/internal/preview/doc.go b/backend/internal/preview/doc.go index 6ef77d9a..3e6c9045 100644 --- a/backend/internal/preview/doc.go +++ b/backend/internal/preview/doc.go @@ -3,18 +3,15 @@ package preview import ( - "bytes" "context" "fmt" - "image/jpeg" "io" "os" - "runtime" "strings" "time" - "github.com/gen2brain/go-fitz" "github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo" + "github.com/gtsteffaniak/go-logger/logger" ) func docEnabled() bool { @@ -23,105 +20,73 @@ func docEnabled() bool { return true } -func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) { +func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int, previewSize string) ([]byte, error) { // Check if context is cancelled before starting if ctx.Err() != nil { return nil, ctx.Err() } - // Serialize access to the entire go-fitz operation block (required for CGO thread safety) - s.docGenMutex.Lock() - defer s.docGenMutex.Unlock() - - // Lock the current goroutine to a single OS thread for CGo calls - runtime.LockOSThread() - defer runtime.UnlockOSThread() - - // Create a 2-second timeout for document generation after acquiring locks - timeoutCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - docPath := file.RealPath // copy file to a temporary location if needed if strings.HasPrefix(file.Type, "text") && !strings.HasSuffix(file.RealPath, ".txt") { originalFile, err := os.Open(file.RealPath) if err != nil { - return nil, fmt.Errorf("text snippet: failed to open original file '%s': %w", file.RealPath, err) + logger.Errorf("text snippet preview: failed to open file '%s': %v", file.Name, err) + return nil, fmt.Errorf("text snippet: failed to open file") } defer originalFile.Close() // Ensure original file is closed buffer := make([]byte, 1024) // Buffer for up to 1KB n, readErr := originalFile.Read(buffer) if readErr != nil && readErr != io.EOF { // io.EOF is not an error if some bytes were read - return nil, fmt.Errorf("text snippet: failed to read from original file '%s': %w", file.RealPath, readErr) + logger.Errorf("text snippet preview: failed to read file '%s': %v", file.Name, readErr) + return nil, fmt.Errorf("text snippet: failed to read file") } if n == 0 { - return nil, fmt.Errorf("text snippet: original file '%s' is empty or unreadable", file.RealPath) + return nil, fmt.Errorf("text snippet: file is empty or unreadable") } else { tempFile, err := os.Create(tempFilePath) if err != nil { - return nil, fmt.Errorf("text snippet: failed to create temporary file '%s': %w", tempFilePath, err) + logger.Errorf("text snippet preview: failed to create temp file for '%s': %v", file.Name, err) + return nil, fmt.Errorf("text snippet: failed to create temporary file") } defer os.Remove(tempFilePath) // Ensure cleanup on error // Write the read content (up to 1KB or EOF) to the temporary file if _, err := tempFile.Write(buffer[:n]); err != nil { tempFile.Close() // Attempt to close os.Remove(tempFilePath) // Clean up on error - return nil, fmt.Errorf("text snippet: failed to write to temporary file '%s': %w", tempFilePath, err) + logger.Errorf("text snippet preview: failed to write temp file for '%s': %v", file.Name, err) + return nil, fmt.Errorf("text snippet: failed to write temporary file") } // Close the temporary file so it can be reliably opened by path by other processes/functions if err := tempFile.Close(); err != nil { os.Remove(tempFilePath) // Clean up on error - return nil, fmt.Errorf("text snippet: failed to close temporary file '%s': %w", tempFilePath, err) + logger.Errorf("text snippet preview: failed to close temp file for '%s': %v", file.Name, err) + return nil, fmt.Errorf("text snippet: failed to close temporary file") } - docPath = tempFilePath // Update docPath to point to the new temporary text snippet file - } + docPath = tempFilePath // Update docPath to point to the new temporary text snippet file + } } - // Check timeout before opening document + timeoutCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + if timeoutCtx.Err() != nil { return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name) } - doc, err := fitz.New(docPath) // This calls the CGo version + imageBytes, err := s.renderDocPageJPEG(timeoutCtx, docPath, pageNumber, previewSize) if err != nil { - return nil, fmt.Errorf("failed to open PDF from memory for file '%s': %w", docPath, err) + logger.Errorf("document preview render failed for '%s': %v", file.Name, err) + return nil, fmt.Errorf("failed to create document preview") } - defer doc.Close() - // Check timeout after opening document if timeoutCtx.Err() != nil { return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name) } - // Get the image from the doc page - numPages := doc.NumPage() - if pageNumber < 0 || pageNumber >= numPages { - return nil, fmt.Errorf("invalid page number %d for PDF with %d pages ('%s')", pageNumber, numPages, docPath) - } - - img, err := doc.Image(pageNumber) - if err != nil { - return nil, fmt.Errorf("failed to get image from page %d of '%s': %w", pageNumber, docPath, err) - } - - // Check timeout after rendering image - if timeoutCtx.Err() != nil { - return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name) - } - - // Create a new buffer to hold the image bytes - var buf bytes.Buffer - - // Encode the image directly into the buffer - err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality}) - if err != nil { - return nil, fmt.Errorf("failed to encode image to jpeg for '%s': %w", docPath, err) - } - - // Return the byte slice from the buffer - return buf.Bytes(), nil + return imageBytes, nil } diff --git a/backend/internal/preview/docDefault.go b/backend/internal/preview/docDefault.go index ffdd9040..dc30eca8 100644 --- a/backend/internal/preview/docDefault.go +++ b/backend/internal/preview/docDefault.go @@ -14,7 +14,8 @@ func docEnabled() bool { return false } -func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) { +func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int, previewSize string) ([]byte, error) { + _ = previewSize // Reference it to prevent unused field warning when building without mupdf _ = &s.docGenMutex return nil, nil diff --git a/backend/internal/preview/doc_cgo_link.go b/backend/internal/preview/doc_cgo_link.go new file mode 100644 index 00000000..34b8ecf9 --- /dev/null +++ b/backend/internal/preview/doc_cgo_link.go @@ -0,0 +1,7 @@ +//go:build mupdf + +package preview + +// Link MuPDF static libraries through go-fitz so preview CGO can call fz_* without +// duplicating platform-specific LDFLAGS in this package. +import _ "github.com/gen2brain/go-fitz" diff --git a/backend/internal/preview/doc_render_cgo.go b/backend/internal/preview/doc_render_cgo.go new file mode 100644 index 00000000..1b8ee017 --- /dev/null +++ b/backend/internal/preview/doc_render_cgo.go @@ -0,0 +1,272 @@ +//go:build mupdf + +package preview + +/* +#cgo CFLAGS: -I${SRCDIR}/gofitzinclude + +#include +#include +#include + +static void fb_silence_warnings(void *user, const char *message) { + (void)user; + (void)message; +} + +static double fb_dmin(double a, double b) { return a < b ? a : b; } +static double fb_dmax(double a, double b) { return a > b ? a : b; } + +// fb_render_document_page_jpeg renders one page to JPEG bytes. +// max_width/max_height <= 0 selects original mode (~150 DPI). Otherwise renders into that box. +// resize_mode_fill: 0 = fit (no upscale), 1 = fill (center crop to exact box). +// Returns 0 on success; on failure writes a message to err_buf and returns -1. +// out is malloc'd; caller must free. +static int fb_render_document_page_jpeg( + const char *path, + int page_num, + int max_width, + int max_height, + int resize_mode_fill, + int jpeg_quality, + unsigned char **out, + size_t *out_len, + char *err_buf, + size_t err_buf_len +) { + fz_context *ctx = NULL; + fz_document *doc = NULL; + fz_page *page = NULL; + fz_pixmap *pixmap = NULL; + fz_device *device = NULL; + fz_buffer *buf = NULL; + unsigned char *jpeg_data = NULL; + size_t jpeg_len = 0; + int rc = -1; + + if (!path || !out || !out_len || !err_buf || err_buf_len == 0) { + return -1; + } + + *out = NULL; + *out_len = 0; + memset(err_buf, 0, err_buf_len); + + ctx = fz_new_context_imp(NULL, NULL, 256 << 20, FZ_VERSION); + if (!ctx) { + snprintf(err_buf, err_buf_len, "cannot create mupdf context"); + return -1; + } + + fz_set_warning_callback(ctx, fb_silence_warnings, NULL); + + fz_var(doc); + fz_var(page); + fz_var(pixmap); + fz_var(device); + fz_var(buf); + fz_var(jpeg_data); + fz_var(jpeg_len); + fz_var(rc); + + fz_try(ctx) { + fz_register_document_handlers(ctx); + + doc = fz_open_document(ctx, path); + if (fz_needs_password(ctx, doc)) { + fz_throw(ctx, FZ_ERROR_ARGUMENT, "document needs password"); + } + + int num_pages = fz_count_pages(ctx, doc); + if (page_num < 0 || page_num >= num_pages) { + fz_throw(ctx, FZ_ERROR_ARGUMENT, "invalid page number"); + } + + page = fz_load_page(ctx, doc, page_num); + + fz_rect bounds = fz_bound_page(ctx, page); + fz_matrix ctm; + fz_irect bbox; + + if (max_width <= 0 || max_height <= 0) { + double dpi = 150.0; + ctm = fz_scale(dpi / 72.0, dpi / 72.0); + bounds = fz_transform_rect(bounds, ctm); + bbox = fz_round_rect(bounds); + } else { + double page_w = bounds.x1 - bounds.x0; + double page_h = bounds.y1 - bounds.y0; + if (page_w <= 0 || page_h <= 0) { + fz_throw(ctx, FZ_ERROR_GENERIC, "invalid page bounds"); + } + + if (resize_mode_fill) { + double scale = fb_dmax((double)max_width / page_w, (double)max_height / page_h); + double tx = ((double)max_width - page_w * scale) / 2.0 - bounds.x0 * scale; + double ty = ((double)max_height - page_h * scale) / 2.0 - bounds.y0 * scale; + ctm = fz_concat( + fz_translate(tx, ty), + fz_concat(fz_scale(scale, scale), fz_translate(-bounds.x0, -bounds.y0)) + ); + bbox = fz_make_irect(0, 0, max_width, max_height); + } else { + double scale = fb_dmin((double)max_width / page_w, (double)max_height / page_h); + if (scale > 1.0) { + scale = 1.0; + } + ctm = fz_concat(fz_scale(scale, scale), fz_translate(-bounds.x0, -bounds.y0)); + bounds = fz_transform_rect(bounds, ctm); + bbox = fz_round_rect(bounds); + } + } + + pixmap = fz_new_pixmap_with_bbox(ctx, fz_device_rgb(ctx), bbox, NULL, 0); + fz_clear_pixmap_with_value(ctx, pixmap, 0xff); + + device = fz_new_draw_device(ctx, ctm, pixmap); + fz_enable_device_hints(ctx, device, FZ_NO_CACHE); + fz_run_page_contents(ctx, page, device, fz_identity, NULL); + fz_close_device(ctx, device); + fz_drop_device(ctx, device); + device = NULL; + + buf = fz_new_buffer_from_pixmap_as_jpeg(ctx, pixmap, fz_default_color_params, jpeg_quality, 0); + jpeg_len = fz_buffer_storage(ctx, buf, &jpeg_data); + if (jpeg_len == 0 || jpeg_data == NULL) { + fz_throw(ctx, FZ_ERROR_GENERIC, "empty jpeg buffer"); + } + + *out = (unsigned char *)malloc(jpeg_len); + if (*out == NULL) { + fz_throw(ctx, FZ_ERROR_SYSTEM, "malloc failed"); + } + memcpy(*out, jpeg_data, jpeg_len); + *out_len = jpeg_len; + rc = 0; + } + fz_always(ctx) { + if (device) { + fz_drop_device(ctx, device); + } + if (pixmap) { + fz_drop_pixmap(ctx, pixmap); + } + if (page) { + fz_drop_page(ctx, page); + } + if (doc) { + fz_drop_document(ctx, doc); + } + if (buf) { + fz_drop_buffer(ctx, buf); + } + } + fz_catch(ctx) { + const char *msg = fz_caught_message(ctx); + if (msg && msg[0] != '\0') { + snprintf(err_buf, err_buf_len, "%s", msg); + } else { + snprintf(err_buf, err_buf_len, "mupdf render failed"); + } + rc = -1; + } + + fz_drop_context(ctx); + + if (rc != 0 && *out != NULL) { + free(*out); + *out = NULL; + *out_len = 0; + } + + return rc; +} +*/ +import "C" + +import ( + "context" + "fmt" + "runtime" + "strings" + "unsafe" +) + +type docRenderParams struct { + maxWidth int + maxHeight int + fill bool + jpegQuality int +} + +func docRenderParamsForPreviewSize(previewSize string) (docRenderParams, error) { + if previewSize == "original" { + return docRenderParams{jpegQuality: QualityHigh.jpegQuality()}, nil + } + opts, err := getPreviewOptions(previewSize) + if err != nil { + return docRenderParams{}, err + } + return docRenderParams{ + maxWidth: opts.Width, + maxHeight: opts.Height, + fill: opts.ResizeMode == ResizeModeFill, + jpegQuality: opts.Quality.jpegQuality(), + }, nil +} + +func (s *Service) renderDocPageJPEG(ctx context.Context, path string, pageNumber int, previewSize string) ([]byte, error) { + params, err := docRenderParamsForPreviewSize(previewSize) + if err != nil { + return nil, err + } + + cPath := C.CString(path) + defer C.free(unsafe.Pointer(cPath)) + + var out *C.uchar + var outLen C.size_t + errBuf := make([]byte, 512) + + fillMode := 0 + if params.fill { + fillMode = 1 + } + + // MuPDF is not safe for concurrent CGO use; serialize only this call. + s.docGenMutex.Lock() + if err := ctx.Err(); err != nil { + s.docGenMutex.Unlock() + return nil, err + } + runtime.LockOSThread() + rc := C.fb_render_document_page_jpeg( + cPath, + C.int(pageNumber), + C.int(params.maxWidth), + C.int(params.maxHeight), + C.int(fillMode), + C.int(params.jpegQuality), + &out, + &outLen, + (*C.char)(unsafe.Pointer(&errBuf[0])), + C.size_t(len(errBuf)), + ) + runtime.UnlockOSThread() + s.docGenMutex.Unlock() + + if rc != 0 { + msg := strings.TrimRight(string(errBuf), "\x00") + if msg == "" { + msg = "mupdf render failed" + } + return nil, fmt.Errorf("%s", msg) + } + + if out == nil || outLen == 0 { + return nil, fmt.Errorf("mupdf render returned empty jpeg") + } + defer C.free(unsafe.Pointer(out)) + + return C.GoBytes(unsafe.Pointer(out), C.int(outLen)), nil +} diff --git a/backend/internal/preview/preview.go b/backend/internal/preview/preview.go index 053b12eb..c253231d 100644 --- a/backend/internal/preview/preview.go +++ b/backend/internal/preview/preview.go @@ -17,10 +17,10 @@ import ( "github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/diskcache" "github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils" - "github.com/gtsteffaniak/filebrowser/backend/internal/imagemeta" - "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" "github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg" + "github.com/gtsteffaniak/filebrowser/backend/internal/imagemeta" "github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo" + "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" "github.com/gtsteffaniak/go-logger/logger" ) @@ -261,7 +261,7 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended switch previewType { case previewTypeDocument: - return s.generateDocumentPreview(ctx, file, hash) + return s.generateDocumentPreview(ctx, file, previewSize, hash) case previewTypeOffice: return s.generateOfficeFilePreview(ctx, file, officeUrl) @@ -270,6 +270,12 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended return s.generateHEICPreview(ctx, file, previewSize) case previewTypeImage: + ext := strings.ToLower(filepath.Ext(file.Name)) + if iteminfo.IsRawImage(ext) && s.ffmpegService != nil { + if bytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize); err == nil && len(bytes) >= minPreviewSize { + return bytes, nil + } + } return s.generateImagePreview(ctx, file, previewSize) case previewTypeVideo: @@ -290,9 +296,9 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended } // generateDocumentPreview generates preview for PDF and document files -func (s *Service) generateDocumentPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, hash string) ([]byte, error) { +func (s *Service) generateDocumentPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, hash string) ([]byte, error) { tempFilePath := filepath.Join(s.cacheDir, "thumbnails", "docs", hash) + ".txt" - imageBytes, err := s.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page + imageBytes, err := s.GenerateImageFromDoc(ctx, file, tempFilePath, 0, previewSize) // 0 for the first page if err != nil { return nil, fmt.Errorf("failed to create image for PDF file: %w", err) } @@ -415,23 +421,26 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo, return nil, ctx.Err() } - // Acquire global image processor semaphore for ALL operations - const largeFileSizeThreshold = 8 * 1024 * 1024 // 8MB - if file.Size >= largeFileSizeThreshold && service.imageLargeSem != nil { - if err := service.acquireImageLargeSem(ctx); err != nil { - return nil, err + // MuPDF document previews render at target size in CGO; they only need docGenMutex. + previewTypeEarly := determinePreviewType(file) + skipImageSem := previewTypeEarly == previewTypeDocument && settings.Env.MuPdfAvailable + if !skipImageSem { + const largeFileSizeThreshold = 8 * 1024 * 1024 // 8MB + if file.Size >= largeFileSizeThreshold && service.imageLargeSem != nil { + if err := service.acquireImageLargeSem(ctx); err != nil { + return nil, err + } + defer service.releaseImageLargeSem() + } else { + if err := service.acquireImageSem(ctx); err != nil { + return nil, err + } + defer service.releaseImageSem() } - defer service.releaseImageLargeSem() - } else { - if err := service.acquireImageSem(ctx); err != nil { - return nil, err - } - defer service.releaseImageSem() - } - // Check if context is cancelled after acquiring semaphore - if ctx.Err() != nil { - return nil, ctx.Err() + if ctx.Err() != nil { + return nil, ctx.Err() + } } // Enforce file size limit for image preview generation to prevent memory exhaustion @@ -482,7 +491,8 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo, // When we got bytes from an embedded preview, we still need to resize to small/large/xlarge (original is never converted). // When we got bytes from type-specific path, regular images are already resized; others need resize below. previewType := determinePreviewType(file) - if !fromEmbeddedPreview && previewType == previewTypeImage { + if !fromEmbeddedPreview && (previewType == previewTypeImage || + (previewType == previewTypeDocument && settings.Env.MuPdfAvailable)) { if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil { logger.Errorf("failed to cache image: %v", err) } diff --git a/backend/internal/utils/media.go b/backend/internal/utils/media.go index afa30a74..7857dcc8 100644 --- a/backend/internal/utils/media.go +++ b/backend/internal/utils/media.go @@ -6,9 +6,10 @@ import ( ) type SubtitleTrack struct { - Name string `json:"name"` // filename for external, or descriptive name for embedded - Language string `json:"language"` // language code - Title string `json:"title,omitempty"` // title/description + Name string `json:"name"` // filename for external, or descriptive name for embedded + Language string `json:"language"` // language code + Srclang string `json:"srclang,omitempty"` // unique HTML track language tag for Plyr (may differ from Language when duplicated) + Title string `json:"title,omitempty"` // title/description Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external) Codec string `json:"codec,omitempty"` // codec name for embedded subtitles Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files diff --git a/backend/internal/utils/path.go b/backend/internal/utils/path.go index 3fa94ca9..66ccde18 100644 --- a/backend/internal/utils/path.go +++ b/backend/internal/utils/path.go @@ -1,6 +1,7 @@ package utils import ( + "path" "path/filepath" "runtime" "strings" @@ -41,6 +42,38 @@ func JoinPathAsUnix(parts ...string) string { return joinedPath } +// CleanIndexPathSegment normalizes an index path segment and clamps ".." at the virtual root. +func CleanIndexPathSegment(segment string) string { + return strings.TrimPrefix(path.Clean("/"+segment), "/") +} + +// JoinScopedIndexPath joins a user's index scope with a sanitized path segment. +// Unlike filepath.Join, a leading slash on rel does not discard scope (avoids absolute-path scope bypass). +func JoinScopedIndexPath(scope, rel string) string { + rel = CleanIndexPathSegment(rel) + scope = strings.TrimRight(scope, "/") + if rel == "" { + if scope == "" || scope == "/" { + return "/" + } + return scope + } + if scope == "" || scope == "/" { + return "/" + rel + } + return scope + "/" + rel +} + +// JoinUnderSourceRoot maps an index path onto a source filesystem root without treating +// indexPath as a host-absolute path (filepath.Join would discard sourceRoot when indexPath starts with "/"). +func JoinUnderSourceRoot(sourceRoot, indexPath string) string { + rel := CleanIndexPathSegment(indexPath) + if rel == "" { + return sourceRoot + } + return filepath.Join(sourceRoot, rel) +} + // AddTrailingSlashIfNotExists ensures a directory index path has a trailing slash (root stays "/"). func AddTrailingSlashIfNotExists(indexPath string) string { if indexPath == "" || indexPath == "/" { diff --git a/backend/internal/utils/path_test.go b/backend/internal/utils/path_test.go new file mode 100644 index 00000000..d375b35e --- /dev/null +++ b/backend/internal/utils/path_test.go @@ -0,0 +1,67 @@ +package utils + +import ( + "path/filepath" + "testing" +) + +func TestJoinScopedIndexPath_absoluteRelDoesNotDropScope(t *testing.T) { + const scope = "/home/alice" + got := JoinScopedIndexPath(scope, "/etc/passwd") + want := "/home/alice/etc/passwd" + if got != want { + t.Fatalf("JoinScopedIndexPath(%q, /etc/passwd) = %q, want %q", scope, got, want) + } +} + +func TestJoinScopedIndexPath_traversalResolvedUnderScope(t *testing.T) { + const scope = "/home/alice" + got := JoinScopedIndexPath(scope, "/../../../etc/passwd") + want := "/home/alice/etc/passwd" + if got != want { + t.Fatalf("JoinScopedIndexPath(%q, /../../../etc/passwd) = %q, want %q", scope, got, want) + } +} + +func TestJoinScopedIndexPath_relativeTraversalWithoutLeadingSlash(t *testing.T) { + const scope = "/home/alice" + got := JoinScopedIndexPath(scope, "../../../etc/passwd") + want := "/home/alice/etc/passwd" + if got != want { + t.Fatalf("JoinScopedIndexPath(%q, ../../../etc/passwd) = %q, want %q", scope, got, want) + } +} + +func TestJoinScopedIndexPath_rootScope(t *testing.T) { + got := JoinScopedIndexPath("/", "projects/acme/file.txt") + if got != "/projects/acme/file.txt" { + t.Fatalf("got %q want /projects/acme/file.txt", got) + } +} + +func TestJoinUnderSourceRoot_absoluteIndexPathStaysUnderSource(t *testing.T) { + const sourceRoot = "/srv/mount" + got := JoinUnderSourceRoot(sourceRoot, "/etc/passwd") + want := filepath.Join(sourceRoot, "etc/passwd") + if got != want { + t.Fatalf("JoinUnderSourceRoot = %q, want %q", got, want) + } +} + +func TestJoinUnderSourceRoot_relativeIndexPath(t *testing.T) { + const sourceRoot = "/srv/mount" + got := JoinUnderSourceRoot(sourceRoot, "/home/alice/projects/foo") + want := filepath.Join(sourceRoot, "home/alice/projects/foo") + if got != want { + t.Fatalf("JoinUnderSourceRoot = %q, want %q", got, want) + } +} + +func TestJoinUnderSourceRoot_relativeTraversalWithoutLeadingSlash(t *testing.T) { + const sourceRoot = "/srv/mount" + got := JoinUnderSourceRoot(sourceRoot, "../../../etc/passwd") + want := filepath.Join(sourceRoot, "etc/passwd") + if got != want { + t.Fatalf("JoinUnderSourceRoot = %q, want %q", got, want) + } +} diff --git a/backend/internal/web/auth.go b/backend/internal/web/auth.go index fbee904d..3ca48b3e 100644 --- a/backend/internal/web/auth.go +++ b/backend/internal/web/auth.go @@ -144,13 +144,17 @@ func getOrCreateAuthenticatedUser(username string, loginMethod users.LoginMethod return nil, err } } - if err := state.SyncUserGroups(username, groups); err != nil { - logger.Warningf("failed to sync ldap user %s groups: %v", username, err) - } // Verify login method matches if userValue.LoginMethod != loginMethod { return nil, errors.ErrWrongLoginMethod } + // Sync IdP groups into access-control GroupMap (write-through). Skip when the + // token/response omitted groups so a missing claim cannot wipe memberships. + if len(groups) > 0 { + if err := state.SyncUserGroups(username, groups); err != nil { + logger.Warningf("failed to sync user %s groups: %v", username, err) + } + } return &userValue, nil } diff --git a/backend/internal/web/download.go b/backend/internal/web/download.go index 1d438bd2..c49827ce 100644 --- a/backend/internal/web/download.go +++ b/backend/internal/web/download.go @@ -189,7 +189,7 @@ func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *Context) ( if err != nil { return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err) } - filePath := utils.JoinPathAsUnix(d.Share.Path, cleanFile) + filePath := utils.JoinScopedIndexPath(d.Share.Path, cleanFile) fileList = append(fileList, filePath) } @@ -242,7 +242,7 @@ func RawFilesHandler(w http.ResponseWriter, r *http.Request, d *Context, source return http.StatusForbidden, err } for i, filePath := range fileList { - fileList[i] = utils.JoinPathAsUnix(userscope, filePath) + fileList[i] = utils.JoinScopedIndexPath(userscope, filePath) } } firstFilePath = fileList[0] diff --git a/backend/internal/web/http.go b/backend/internal/web/http.go index 4744e4fa..81a5e668 100644 --- a/backend/internal/web/http.go +++ b/backend/internal/web/http.go @@ -127,6 +127,8 @@ func ErrToStatus(err error) int { return http.StatusForbidden case errors.Is(err, libErrors.ErrAccessDenied): return http.StatusForbidden + case errors.Is(err, libErrors.ErrPathEscapesScope): + return http.StatusForbidden case os.IsNotExist(err), err == libErrors.ErrNotExist: return http.StatusNotFound case os.IsExist(err), err == libErrors.ErrExist: diff --git a/backend/internal/web/middleware.go b/backend/internal/web/middleware.go index 3e64f46e..b77384a2 100644 --- a/backend/internal/web/middleware.go +++ b/backend/internal/web/middleware.go @@ -201,7 +201,10 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U data.Token = tokenString var tk users.AuthToken - token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc) + // Validate signature without enforcing exp/iat so expired session JWTs can still yield + // user context on public-share routes; revocation is checked explicitly below. + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + token, err := parser.ParseWithClaims(tokenString, &tk, keyFunc) if err != nil { return nil } @@ -210,8 +213,9 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U return nil } - // Token is valid (but might be expired or revoked) - // Try to get the user regardless of expiration status + if state.IsTokenRevoked(tokenString) { + return nil + } var user *users.User userValue, err := state.UserFromAPIToken(tk, tokenString) if err == nil { @@ -455,10 +459,6 @@ func withUserHelper(fn handleFunc) handleFunc { if tk.RegisteredClaims.ExpiresAt == nil { return http.StatusUnauthorized, fmt.Errorf("token is invalid or revoked") } - // Check if token is about to expire for renewal header - if tk.RegisteredClaims.ExpiresAt.Unix() < time.Now().Add(time.Minute*30).Unix() { - w.Header().Add("X-Renew-Token", "true") - } userValue, err := state.UserFromAPIToken(tk, data.Token) if err == nil { data.User = &userValue diff --git a/backend/internal/web/middleware_test.go b/backend/internal/web/middleware_test.go index ad7a69b8..57e30301 100644 --- a/backend/internal/web/middleware_test.go +++ b/backend/internal/web/middleware_test.go @@ -149,6 +149,175 @@ func TestWithAdminHelper(t *testing.T) { } } +func TestPublicShare_RejectsRevokedJWT(t *testing.T) { + setupTestEnv(t) + + victim := &users.User{ + FrontendUser: users.FrontendUser{ + Username: "victim", + Permissions: users.Permissions{Api: true}, + }, + BackendScopes: []users.BackendScope{ + {Path: "/srv", Scope: "/"}, + }, + } + if err := state.CreateUser(victim, ""); err != nil { + t.Fatal("failed to create victim user:", err) + } + victim.Permissions = users.Permissions{Api: true} + if err := state.UpdateUser(victim, "", "permissions"); err != nil { + t.Fatal("failed to set victim permissions:", err) + } + + originalAuthKey := settings.Config.Auth.Key + settings.Config.Auth.Key = "key" + t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey }) + + tokenString, _, err := auth.MakeSignedTokenAPI(victim, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, victim.Permissions, false) + if err != nil { + t.Fatalf("failed to issue token: %v", err) + } + + shareLink := &share.Share{ + ShareSettings: share.ShareSettings{ + ShareLimits: share.ShareLimits{ + SourceName: "srv", + AllowedUsernames: []string{"victim"}, + }, + }, + ShareColumns: share.ShareColumns{ + Hash: "revoked_jwt_hash", + Path: "/", + }, + SourcePath: "/srv", + UserID: victim.ID, + } + if err := state.CreateShare(shareLink); err != nil { + t.Fatal("failed to create share:", err) + } + + handler := withHashFile(publicGetResourceHandler) + + makeRequest := func(jwt string) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/public/api/resources?hash=revoked_jwt_hash&path=/", http.NoBody) + req.AddCookie(&http.Cookie{ + Name: "filebrowser_quantum_jwt", + Value: jwt, + }) + handler(recorder, req) + return recorder + } + + // Valid token: allow-listed user can access the share. + rec := makeRequest(tokenString) + if rec.Code != http.StatusOK { + t.Fatalf("valid token: expected status %d, got %d", http.StatusOK, rec.Code) + } + + if err := state.RevokeToken(tokenString); err != nil { + t.Fatalf("failed to revoke token: %v", err) + } + + // Revoked token must not resurrect the victim on public-share routes. + rec = makeRequest(tokenString) + if rec.Code == http.StatusOK { + t.Fatalf("revoked token: expected non-200 status, got %d (JWT resurrection bypass)", rec.Code) + } +} + +func TestExtractUserFromExpiredToken_RejectsRevokedJWT(t *testing.T) { + _, tokenString := issueExtractUserTestToken(t, time.Hour*2) + if err := state.RevokeToken(tokenString); err != nil { + t.Fatalf("failed to revoke token: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/public/api/resources", http.NoBody) + req.AddCookie(&http.Cookie{ + Name: "filebrowser_quantum_jwt", + Value: tokenString, + }) + + data := &requestContext{} + if got := extractUserFromExpiredToken(req, data); got != nil { + t.Fatalf("extractUserFromExpiredToken() = user %q, want nil for revoked token", got.Username) + } +} + +func TestExtractUserFromExpiredToken_AcceptsExpiredNonRevoked(t *testing.T) { + user, tokenString := issueExtractUserTestToken(t, -time.Hour) + + req := httptest.NewRequest(http.MethodGet, "/public/api/resources", http.NoBody) + req.AddCookie(&http.Cookie{ + Name: "filebrowser_quantum_jwt", + Value: tokenString, + }) + + data := &requestContext{} + got := extractUserFromExpiredToken(req, data) + if got == nil { + t.Fatal("extractUserFromExpiredToken() = nil, want user for expired non-revoked token") + } + if got.Username != user.Username { + t.Fatalf("extractUserFromExpiredToken() username = %q, want %q", got.Username, user.Username) + } +} + +func TestExtractUserFromExpiredToken_RejectsRevokedExpired(t *testing.T) { + _, tokenString := issueExtractUserTestToken(t, -time.Hour) + if err := state.RevokeToken(tokenString); err != nil { + t.Fatalf("failed to revoke token: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/public/api/resources", http.NoBody) + req.AddCookie(&http.Cookie{ + Name: "filebrowser_quantum_jwt", + Value: tokenString, + }) + + data := &requestContext{} + if got := extractUserFromExpiredToken(req, data); got != nil { + t.Fatalf("extractUserFromExpiredToken() = user %q, want nil for revoked expired token", got.Username) + } +} + +func issueExtractUserTestToken(t *testing.T, duration time.Duration) (*users.User, string) { + t.Helper() + setupTestEnv(t) + + stored := &users.User{ + FrontendUser: users.FrontendUser{ + Username: "victim", + Permissions: users.Permissions{Api: true}, + }, + } + if err := state.CreateUser(stored, ""); err != nil { + t.Fatal("failed to create user:", err) + } + user, err := state.GetUserByUsername("victim") + if err != nil { + t.Fatal("failed to load user:", err) + } + user.Permissions = users.Permissions{Api: true} + if err = state.UpdateUser(&user, "", "permissions"); err != nil { + t.Fatal("failed to set user permissions:", err) + } + user, err = state.GetUserByUsername("victim") + if err != nil { + t.Fatal("failed to reload user:", err) + } + + originalAuthKey := settings.Config.Auth.Key + settings.Config.Auth.Key = "key" + t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey }) + + tokenString, _, err := auth.MakeSignedTokenAPI(&user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), duration, user.Permissions, false) + if err != nil { + t.Fatalf("failed to issue token: %v", err) + } + return &user, tokenString +} + func TestPublicShareHandlerAuthentication(t *testing.T) { setupTestEnv(t) diff --git a/backend/internal/web/resource.go b/backend/internal/web/resource.go index 93b32f35..163f8cb5 100644 --- a/backend/internal/web/resource.go +++ b/backend/internal/web/resource.go @@ -2,8 +2,6 @@ package web import ( "context" - "crypto/md5" - "encoding/hex" "encoding/json" "fmt" "io" @@ -25,10 +23,9 @@ import ( "github.com/gtsteffaniak/filebrowser/backend/internal/utils" "github.com/gtsteffaniak/filebrowser/backend/pkg/indexing" "github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo" + "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" "github.com/gtsteffaniak/go-cache/cache" "github.com/gtsteffaniak/go-logger/logger" - "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" - ) var pauseCache = cache.NewCache[string](1 * time.Minute) @@ -393,7 +390,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex if err != nil { response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: err.Error(), }) continue @@ -404,7 +401,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex if sourceName == "" { response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: "source name is empty", }) continue @@ -414,7 +411,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex if idx == nil { response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: "source not found", }) continue @@ -423,7 +420,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex if idx.Config.ReadOnly { response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: "source is read-only", }) continue @@ -434,13 +431,13 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex if err != nil { response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: "user does not have access", }) continue } withoutUserScope := strings.TrimPrefix(d.Share.Path, userScope) - indexPath := utils.JoinPathAsUnix(withoutUserScope, sanitizedPath) + indexPath := utils.JoinScopedIndexPath(withoutUserScope, sanitizedPath) fileInfo, err := files.FileInfoFaster(utils.FileOptions{ FollowSymlinks: true, @@ -458,7 +455,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err) response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: "error deleting file/directory, admin must check the logs", }) continue @@ -470,7 +467,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex if item.Source == "" { response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: "source was empty, source is required", }) continue @@ -481,7 +478,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex if err != nil { response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: fmt.Sprintf("user does not have access: %v", err), }) continue @@ -490,7 +487,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex if permErr != nil || !filePerms.Delete { response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: "user is not allowed to delete", }) continue @@ -500,7 +497,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex if idx == nil { response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: "source not found", }) continue @@ -509,7 +506,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex if idx.Config.ReadOnly { response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: "source is read-only", }) continue @@ -525,7 +522,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex if err != nil { response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: err.Error(), }) continue @@ -534,16 +531,18 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex if err != nil { response.Failed = append(response.Failed, BulkDeleteItem{ Source: item.Source, - Path: sanitizedPath, + Path: rawPath, Message: err.Error(), }) continue } preview.DelThumbs(r.Context(), *fileInfo) } - // Success (log canonical path/source used for deletion) - loggedItem := item - loggedItem.Path = sanitizedPath + // Success — echo the client path in the response + loggedItem := BulkDeleteItem{ + Source: item.Source, + Path: rawPath, + } if d.Share.Hash != "" { loggedItem.Source = d.Share.GetSourceName() } @@ -629,7 +628,7 @@ func resourcePauseHandler(w http.ResponseWriter, r *http.Request, d *Context) (i if err != nil { return http.StatusForbidden, err } - fullIndexPath := utils.JoinPathAsUnix(userscope, cleanPath) + fullIndexPath := utils.JoinScopedIndexPath(userscope, cleanPath) if !state.AccessPermitted(idx.Path, utils.IndexPathFromNormalized(fullIndexPath, true), d.User.Username) { return http.StatusForbidden, fmt.Errorf("access denied to path %s", fullIndexPath) } @@ -718,7 +717,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in } userscope = strings.TrimRight(userscope, "/") - fullIndexPath := utils.JoinPathAsUnix(userscope, path) + fullIndexPath := utils.JoinScopedIndexPath(userscope, path) // get scoped path realPath, _, _ := idx.GetRealPath(fullIndexPath) @@ -782,12 +781,34 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in } var totalSize int64 - totalSizeStr := r.Header.Get("X-File-Total-Size") - totalSize, err = strconv.ParseInt(totalSizeStr, 10, 64) + var hasTotalSize bool + totalSize, hasTotalSize, err = parseUploadTotalSize(r) if err != nil { - logger.Debugf("invalid total size: %v", err) - return http.StatusBadRequest, fmt.Errorf("invalid total size: %v", err) + logger.Debugf("%v", err) + return http.StatusBadRequest, err } + if !hasTotalSize { + return http.StatusBadRequest, fmt.Errorf("invalid total size: missing X-File-Total-Size") + } + if boundsErr := validateChunkBounds(offset, r.ContentLength, totalSize); boundsErr != nil { + logger.Debugf("%v", boundsErr) + return http.StatusBadRequest, boundsErr + } + + sessionID, sessionErr := parseUploadSession(r) + if sessionErr != nil { + logger.Debugf("%v", sessionErr) + return http.StatusBadRequest, sessionErr + } + if acquireErr := activeUploadSessions.acquire(realPath, sessionID); acquireErr != nil { + if isUploadSessionConflict(acquireErr) { + logger.Debugf("%v", acquireErr) + return http.StatusConflict, acquireErr + } + logger.Debugf("%v", acquireErr) + return http.StatusInternalServerError, acquireErr + } + // On the first chunk, check for conflicts or handle override if offset == 0 { // Check for file/folder conflicts for chunked uploads @@ -814,11 +835,8 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in } } - // Use a temporary file in the cache directory for chunks. - // Create a unique name for the temporary file to avoid collisions. - hasher := md5.New() - hasher.Write([]byte(realPath)) - tempFilePath := fmt.Sprintf("%s.%s.uploading.tmp", realPath, hex.EncodeToString(hasher.Sum(nil))) + // Use a temporary file for chunks. + tempFilePath := uploadTempPath(realPath, sessionID) // Create or open the temporary file var outFile *os.File outFile, err = os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY, fileutils.PermFile) @@ -860,15 +878,51 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in } } + // Keep the partial temp file so the client can resume from offset. if gracefulPause { logger.Debugf("chunk upload ended after graceful pause; keeping partial file (source=%s path=%s)", source, path) return 499, nil } - _ = os.Remove(tempFilePath) + logger.Debugf("chunk upload failed; keeping partial file for resume (source=%s path=%s offset=%d)", source, path, offset) return http.StatusInternalServerError, fmt.Errorf("could not write chunk to temp file: %v", err) } - // check if the file is complete - if (offset + chunkSize) >= totalSize { + + if err = validateReceivedBytes(chunkSize, 0, false, r.ContentLength); err != nil { + logger.Debugf("incomplete chunk: %v", err) + if truncErr := outFile.Truncate(offset); truncErr != nil { + logger.Debugf("could not truncate temp file after incomplete chunk (offset=%d): %v", offset, truncErr) + } + _ = outFile.Sync() + return http.StatusBadRequest, err + } + + if err = validateAssembledSize(offset, chunkSize, totalSize); err != nil { + logger.Debugf("%v", err) + if truncErr := outFile.Truncate(offset); truncErr != nil { + logger.Debugf("could not truncate temp file after oversized chunk (offset=%d): %v", offset, truncErr) + } + _ = outFile.Sync() + return http.StatusBadRequest, err + } + assembled := offset + chunkSize + + // Finalize only when the assembled size matches the declared total exactly. + if assembled == totalSize { + if syncErr := outFile.Sync(); syncErr != nil { + logger.Debugf("could not sync temp file before finalize: %v", syncErr) + return http.StatusInternalServerError, fmt.Errorf("could not sync temp file: %v", syncErr) + } + var info os.FileInfo + info, err = outFile.Stat() + if err != nil { + logger.Debugf("could not stat temp file before finalize: %v", err) + return http.StatusInternalServerError, fmt.Errorf("could not stat temp file: %v", err) + } + if info.Size() != totalSize { + logger.Debugf("assembled file size mismatch: got %d want %d", info.Size(), totalSize) + _ = os.Remove(tempFilePath) + return http.StatusBadRequest, fmt.Errorf("upload incomplete: expected %d bytes, received %d", totalSize, info.Size()) + } // close file before moving outFile.Close() // Move the completed file from the temp location to the final destination @@ -879,6 +933,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in } reconcileSharesAfterMove(false, source, source, tempFilePath, realPath) activity.RecordUpload(r, toActor(d), source, path, false) + activeUploadSessions.release(realPath, sessionID) } return http.StatusOK, nil } @@ -893,12 +948,82 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in preview.DelThumbs(r.Context(), *fileInfo) } - err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body) + if !isContentUpload(r) { + err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body) + if err != nil { + logger.Debugf("error writing file: %v", err) + return ErrToStatus(err), err + } + activity.RecordUpload(r, toActor(d), source, path, false) + return http.StatusOK, nil + } + + totalSize, hasTotalSize, err := parseUploadTotalSize(r) if err != nil { + logger.Debugf("%v", err) + return http.StatusBadRequest, err + } + + // Write to a temp file, verify size, then move into place so truncated + // uploads never leave a corrupt destination file. + if err = os.MkdirAll(filepath.Dir(realPath), fileutils.EffectiveDirPerm()); err != nil { + logger.Debugf("could not create parent directory: %v", err) + return http.StatusInternalServerError, fmt.Errorf("could not create parent directory: %v", err) + } + + sessionID, sessionErr := parseUploadSession(r) + if sessionErr != nil { + logger.Debugf("%v", sessionErr) + return http.StatusBadRequest, sessionErr + } + if acquireErr := activeUploadSessions.acquire(realPath, sessionID); acquireErr != nil { + if isUploadSessionConflict(acquireErr) { + logger.Debugf("%v", acquireErr) + return http.StatusConflict, acquireErr + } + logger.Debugf("%v", acquireErr) + return http.StatusInternalServerError, acquireErr + } + + tempFilePath := uploadTempPath(realPath, sessionID) + outFile, err := os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fileutils.PermFile) + if err != nil { + logger.Debugf("could not open temp file: %v", err) + return http.StatusInternalServerError, fmt.Errorf("could not open temp file: %v", err) + } + + written, copyErr := io.Copy(outFile, r.Body) + closeErr := outFile.Close() + if copyErr != nil { + _ = os.Remove(tempFilePath) + activeUploadSessions.release(realPath, sessionID) + logger.Debugf("error writing file: %v", copyErr) + return ErrToStatus(copyErr), copyErr + } + if closeErr != nil { + _ = os.Remove(tempFilePath) + activeUploadSessions.release(realPath, sessionID) + logger.Debugf("error closing temp file: %v", closeErr) + return http.StatusInternalServerError, closeErr + } + + if err = validateReceivedBytes(written, totalSize, hasTotalSize, r.ContentLength); err != nil { + _ = os.Remove(tempFilePath) + activeUploadSessions.release(realPath, sessionID) + logger.Debugf("%v", err) + return http.StatusBadRequest, err + } + + err = files.MoveResource(false, source, source, tempFilePath, realPath) + if err != nil { + _ = os.Remove(tempFilePath) + activeUploadSessions.release(realPath, sessionID) logger.Debugf("error writing file: %v", err) return ErrToStatus(err), err } + reconcileSharesAfterMove(false, source, source, tempFilePath, realPath) activity.RecordUpload(r, toActor(d), source, path, false) + activeUploadSessions.release(realPath, sessionID) return http.StatusOK, nil } @@ -984,7 +1109,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int if err != nil { return http.StatusForbidden, err } - fullIndexPath := utils.JoinPathAsUnix(userScope, path) + fullIndexPath := utils.JoinScopedIndexPath(userScope, path) // Check access control for the target path idx := indexing.GetIndex(source) if idx == nil { @@ -1038,7 +1163,7 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, return http.StatusBadRequest, err } - resolvedPath := utils.JoinPathAsUnix(d.Share.Path, cleanPath) + resolvedPath := utils.JoinScopedIndexPath(d.Share.Path, cleanPath) err = files.WriteFile(sourceName, resolvedPath, r.Body) if err != nil { logger.Errorf("public put handler: error updating resource with error %v", err) @@ -1090,9 +1215,11 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i // Process each item for _, item := range req.Items { + clientFromPath := item.FromPath + clientToPath := item.ToPath // Validate all fields are provided - if item.FromSource == "" || item.FromPath == "" || item.ToSource == "" || item.ToPath == "" { + if item.FromSource == "" || clientFromPath == "" || item.ToSource == "" || clientToPath == "" { item.Message = "fromSource, fromPath, toSource, and toPath are required" if d.Share.Hash != "" { response.Failed = append(response.Failed, MoveCopyItem{ @@ -1100,23 +1227,21 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i }) continue } - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } - cleanFromPath, err := utils.SanitizePath(item.FromPath) + cleanFromPath, err := utils.SanitizePath(clientFromPath) if err != nil { item.Message = fmt.Sprintf("invalid fromPath: %v", err) - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } - cleanToPath, err := utils.SanitizePath(item.ToPath) + cleanToPath, err := utils.SanitizePath(clientToPath) if err != nil { item.Message = fmt.Sprintf("invalid toPath: %v", err) - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } - item.FromPath = cleanFromPath - item.ToPath = cleanToPath // Get user scopes for both sources // For shares, paths are already absolute, so use empty scope @@ -1126,19 +1251,19 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i userscopeSrc, err = d.User.GetScopeForSourceName(item.FromSource) if err != nil { item.Message = "source not available" - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } userscopeDst, err = d.User.GetScopeForSourceName(item.ToSource) if err != nil { item.Message = "destination source not available" - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } fromPerms, permErr := effectiveFilePerms(d, item.FromSource) if permErr != nil { item.Message = "permission denied" - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } toPerms := fromPerms @@ -1147,13 +1272,13 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i toPerms, toErr = effectiveFilePerms(d, item.ToSource) if toErr != nil { item.Message = "permission denied" - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } } if msg := resourcePatchPermCheck(req.Action, item.FromSource, item.ToSource, fromPerms, toPerms); msg != "" { item.Message = msg - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } } @@ -1168,7 +1293,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i }) continue } - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } @@ -1182,7 +1307,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i }) continue } - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } @@ -1194,7 +1319,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i }) continue } - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } @@ -1206,16 +1331,16 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i }) continue } - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } // Build full index paths for access control - fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath) - fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath) + fullSrcIndexPath := utils.JoinScopedIndexPath(userscopeSrc, cleanFromPath) + fullDstIndexPath := utils.JoinScopedIndexPath(userscopeDst, cleanToPath) if fullDstIndexPath == "/" || fullSrcIndexPath == "/" { item.Message = "source or destination is the root or unautharized directory" - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } @@ -1228,7 +1353,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i }) continue } - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } if !state.AccessPermitted(dstIdx.Path, utils.IndexPathFromNormalized(fullDstIndexPath, true), d.User.Username) { @@ -1239,16 +1364,14 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i }) continue } - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } - // Get real paths - // Combine user scope with item paths BEFORE calling GetRealPath to avoid double scope application - fullSrcPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath) - realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcPath) + // Get real paths (fullSrcIndexPath already includes user scope) + realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcIndexPath) if err != nil { - logger.Errorf("could not resolve source path: %v, item.FromPath: %v", err, item.FromPath) + logger.Errorf("could not resolve source path: %v, fromPath: %v", err, clientFromPath) item.Message = "could not resolve source path" if d.Share.Hash != "" { response.Failed = append(response.Failed, MoveCopyItem{ @@ -1256,13 +1379,13 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i }) continue } - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } // Check destination parent directory exists - dstParentPath := filepath.Dir(item.ToPath) - fullDstParentPath := utils.JoinPathAsUnix(userscopeDst, dstParentPath) + dstParentPath := filepath.Dir(cleanToPath) + fullDstParentPath := utils.JoinScopedIndexPath(userscopeDst, dstParentPath) parentDir, _, err := dstIdx.GetRealPath(fullDstParentPath) if err != nil { item.Message = "destination directory does not exist" @@ -1272,10 +1395,10 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i }) continue } - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } - realDest := parentDir + "/" + filepath.Base(item.ToPath) + realDest := parentDir + "/" + filepath.Base(cleanToPath) // Auto-rename if requested if req.Rename { @@ -1292,7 +1415,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i }) continue } - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } } @@ -1317,13 +1440,13 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i continue } item.Message = err.Error() - response.Failed = append(response.Failed, item) + response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) continue } // Success - response.Succeeded = append(response.Succeeded, item) - activity.RecordPatchItem(r, toActor(d), req.Action, activity.MoveCopyItem{FromSource: item.FromSource, FromPath: item.FromPath, ToPath: item.ToPath}) + response.Succeeded = append(response.Succeeded, moveCopyWithClientPaths(item, clientFromPath, clientToPath)) + activity.RecordPatchItem(r, toActor(d), req.Action, activity.MoveCopyItem{FromSource: item.FromSource, FromPath: clientFromPath, ToPath: clientToPath}) } if len(response.Failed) == 0 && len(response.Succeeded) == 0 { @@ -1411,13 +1534,13 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int return http.StatusBadRequest, fmt.Errorf("invalid from path: %w", err) } req.Items[i].FromSource = sourceName - req.Items[i].FromPath = utils.JoinPathAsUnix(d.Share.Path, sanitizedPath) + req.Items[i].FromPath = utils.JoinScopedIndexPath(d.Share.Path, sanitizedPath) sanitizedPath, err = utils.SanitizePath(req.Items[i].ToPath) if err != nil { return http.StatusBadRequest, fmt.Errorf("invalid to path: %w", err) } req.Items[i].ToSource = sourceName - req.Items[i].ToPath = utils.JoinPathAsUnix(d.Share.Path, sanitizedPath) + req.Items[i].ToPath = utils.JoinScopedIndexPath(d.Share.Path, sanitizedPath) } d.Data = req @@ -1595,6 +1718,12 @@ func publicItemsGetHandler(w http.ResponseWriter, r *http.Request, d *Context) ( return RenderJSON(w, r, items) } +func moveCopyWithClientPaths(item MoveCopyItem, clientFrom, clientTo string) MoveCopyItem { + item.FromPath = clientFrom + item.ToPath = clientTo + return item +} + func bulkDeleteActivityItems(items []BulkDeleteItem) []activity.BulkDeleteItem { out := make([]activity.BulkDeleteItem, len(items)) for i, it := range items { diff --git a/backend/internal/web/resource_upload_test.go b/backend/internal/web/resource_upload_test.go new file mode 100644 index 00000000..401f58ee --- /dev/null +++ b/backend/internal/web/resource_upload_test.go @@ -0,0 +1,562 @@ +package web + +import ( + "bytes" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + + "github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils" + "github.com/gtsteffaniak/filebrowser/backend/internal/database/users" + "github.com/gtsteffaniak/filebrowser/backend/pkg/indexing" + "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" +) + +var uploadTestResolverMu sync.Mutex + +func setupUploadHTTPTest(t *testing.T) (root string, user *users.User) { + t.Helper() + uploadTestResolverMu.Lock() + + prevFilePerm := fileutils.PermFile + prevDirPerm := fileutils.PermDir + prevSourceMap := settings.Config.Server.SourceMap + prevNameToSource := settings.Config.Server.NameToSource + prevSourceResolver := users.GetSourceNameResolver() + prevSourceConfig := users.GetSourceConfig() + + t.Cleanup(func() { + indexing.ClearTestIndices() + fileutils.SetFsPermissions(uint32(prevFilePerm), uint32(prevDirPerm)) + settings.Config.Server.SourceMap = prevSourceMap + settings.Config.Server.NameToSource = prevNameToSource + users.SetSourceNameResolver(prevSourceResolver) + users.SetSourceConfig(prevSourceConfig) + uploadTestResolverMu.Unlock() + }) + + fileutils.SetFsPermissions(0o644, 0o755) + + root = t.TempDir() + sourceName := "uploads" + sourcePath := root + + indexing.SetTestIndex(sourceName, sourcePath) + idx := indexing.GetIndex(sourceName) + if idx == nil { + t.Fatal("expected test index") + } + idx.Config.ResolvedRules.IndexingDisabled = true + + settings.Config.Server.SourceMap = map[string]*settings.Source{ + sourcePath: {Path: sourcePath, Name: sourceName}, + } + settings.Config.Server.NameToSource = map[string]*settings.Source{ + sourceName: settings.Config.Server.SourceMap[sourcePath], + } + + users.SetSourceNameResolver(func(name string) (string, error) { + if name == sourceName { + return sourcePath, nil + } + return "", fmt.Errorf("unknown source %q", name) + }) + users.SetSourceConfig(&users.SourceConfigProvider{ + GetSourceByPath: func(path string) (users.SourceInfo, bool) { + if path == sourcePath { + return users.SourceInfo{Path: sourcePath, Name: sourceName}, true + } + return users.SourceInfo{}, false + }, + GetSourceByName: func(name string) (users.SourceInfo, bool) { + if name == sourceName { + return users.SourceInfo{Path: sourcePath, Name: sourceName}, true + } + return users.SourceInfo{}, false + }, + }) + + perms := users.SourceFilePermissions{ + View: true, Download: true, Modify: true, Create: true, Delete: true, + } + user = &users.User{ + ID: 1, + FrontendUser: users.FrontendUser{ + Username: "uploader", + }, + BackendScopes: []users.BackendScope{ + {Path: sourcePath, Scope: "/", Permissions: perms}, + }, + BackendSourcePermissions: map[string]users.SourceFilePermissions{ + sourcePath: perms, + }, + Version: users.SourcePermissionsMigrationVersion, + } + return root, user +} + +func postUpload( + t *testing.T, + user *users.User, + path string, + body io.Reader, + contentLength int64, + headers map[string]string, +) (int, error) { + t.Helper() + url := "/api/resources?source=uploads&path=" + path + req := httptest.NewRequest(http.MethodPost, url, body) + if contentLength >= 0 { + req.ContentLength = contentLength + } + if headers == nil { + headers = map[string]string{} + } + if _, ok := headers["X-File-Upload-Session"]; !ok { + headers["X-File-Upload-Session"] = "test-session" + } + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + return ResourcePostHandler(rec, req, &requestContext{User: user}) +} + +func TestParseUploadTotalSize(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + total, ok, err := parseUploadTotalSize(req) + if err != nil || ok || total != 0 { + t.Fatalf("empty header: total=%d ok=%v err=%v", total, ok, err) + } + + req.Header.Set("X-File-Total-Size", "12345") + total, ok, err = parseUploadTotalSize(req) + if err != nil || !ok || total != 12345 { + t.Fatalf("valid header: total=%d ok=%v err=%v", total, ok, err) + } + + req.Header.Set("X-File-Total-Size", "nope") + _, _, err = parseUploadTotalSize(req) + if err == nil { + t.Fatal("expected parse error") + } +} + +func TestValidateReceivedBytes(t *testing.T) { + t.Parallel() + + if err := validateReceivedBytes(10, 10, true, 10); err != nil { + t.Fatalf("matching sizes: %v", err) + } + if err := validateReceivedBytes(9, 10, true, -1); err == nil { + t.Fatal("expected total size mismatch") + } + if err := validateReceivedBytes(9, 0, false, 10); err == nil { + t.Fatal("expected content-length mismatch") + } + if err := validateReceivedBytes(10, 0, false, -1); err != nil { + t.Fatalf("no expected sizes: %v", err) + } +} + +func TestIsContentUpload(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + if isContentUpload(req) { + t.Fatal("empty create request should not be a content upload") + } + req.Header.Set("X-File-Total-Size", "0") + if !isContentUpload(req) { + t.Fatal("expected total-size header to mark content upload") + } + req = httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("X-File-Upload-Session", "abc") + if !isContentUpload(req) { + t.Fatal("expected upload session header to mark content upload") + } +} + +func TestValidateChunkBounds(t *testing.T) { + t.Parallel() + + if err := validateChunkBounds(0, 10, 100); err != nil { + t.Fatalf("valid bounds: %v", err) + } + if err := validateChunkBounds(-1, 10, 100); err == nil { + t.Fatal("expected negative offset error") + } + if err := validateChunkBounds(0, 10, -1); err == nil { + t.Fatal("expected negative total size error") + } + if err := validateChunkBounds(101, 1, 100); err == nil { + t.Fatal("expected offset exceeds total error") + } + if err := validateChunkBounds(90, 20, 100); err == nil { + t.Fatal("expected chunk exceeds remaining error") + } + if err := validateChunkBounds(math.MaxInt64, 1, math.MaxInt64); err == nil { + t.Fatal("expected offset overflow error") + } + if err := validateAssembledSize(90, 20, 100); err == nil { + t.Fatal("expected assembled size error") + } + if err := validateAssembledSize(0, -1, 100); err == nil { + t.Fatal("expected negative chunk size error") + } +} + +func TestParseUploadSession(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + if _, err := parseUploadSession(req); err == nil { + t.Fatal("expected missing session error") + } + + req.Header.Set(uploadSessionHeader, "valid-Session_123") + got, err := parseUploadSession(req) + if err != nil || got != "valid-Session_123" { + t.Fatalf("valid session: got=%q err=%v", got, err) + } + + req.Header.Set(uploadSessionHeader, "bad token!") + if _, err = parseUploadSession(req); err == nil { + t.Fatal("expected invalid character error") + } + + req.Header.Set(uploadSessionHeader, strings.Repeat("a", 129)) + if _, err = parseUploadSession(req); err == nil { + t.Fatal("expected oversized session error") + } +} + +func TestUploadTempPathStable(t *testing.T) { + t.Parallel() + a := uploadTempPath("/data/photo.jpg", "session-a") + b := uploadTempPath("/data/photo.jpg", "session-a") + if a != b { + t.Fatalf("expected stable temp path, got %q and %q", a, b) + } + if a == uploadTempPath("/data/photo.jpg", "session-b") { + t.Fatal("expected different sessions to use different temp paths") + } + if filepath.Ext(a) != ".tmp" { + t.Fatalf("expected .tmp suffix, got %q", a) + } +} + +func TestResourcePostHandler_CreateEmptyFileWithoutSession(t *testing.T) { + root, user := setupUploadHTTPTest(t) + status, err := postUpload(t, user, "/empty.txt", bytes.NewReader(nil), 0, map[string]string{ + "X-File-Upload-Session": "", + }) + if status != http.StatusOK || err != nil { + t.Fatalf("status=%d err=%v", status, err) + } + got, err := os.ReadFile(filepath.Join(root, "empty.txt")) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("expected empty file, got %q", got) + } +} + +func TestResourcePostHandler_NonChunkedMatchesTotalSize(t *testing.T) { + root, user := setupUploadHTTPTest(t) + body := []byte("hello-image-bytes") + status, err := postUpload(t, user, "/photo.jpg", bytes.NewReader(body), int64(len(body)), map[string]string{ + "X-File-Total-Size": strconv.Itoa(len(body)), + }) + if status != http.StatusOK || err != nil { + t.Fatalf("status=%d err=%v", status, err) + } + got, err := os.ReadFile(filepath.Join(root, "photo.jpg")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, body) { + t.Fatalf("file contents mismatch: got %q", got) + } + if matches, _ := filepath.Glob(filepath.Join(root, "*.uploading.tmp")); len(matches) != 0 { + t.Fatalf("expected no leftover temp files, got %v", matches) + } +} + +func TestResourcePostHandler_NonChunkedTruncatedBody(t *testing.T) { + root, user := setupUploadHTTPTest(t) + body := []byte("partial") + status, err := postUpload(t, user, "/photo.jpg", bytes.NewReader(body), int64(len(body)), map[string]string{ + "X-File-Total-Size": "100", + }) + if status != http.StatusBadRequest || err == nil { + t.Fatalf("status=%d err=%v, want 400", status, err) + } + if _, err := os.Stat(filepath.Join(root, "photo.jpg")); !os.IsNotExist(err) { + t.Fatalf("expected no final file, stat err=%v", err) + } + if matches, _ := filepath.Glob(filepath.Join(root, "*.uploading.tmp")); len(matches) != 0 { + t.Fatalf("expected temp removed, got %v", matches) + } +} + +func TestResourcePostHandler_ChunkedTwoChunksComplete(t *testing.T) { + root, user := setupUploadHTTPTest(t) + part1 := []byte("aaaa") + part2 := []byte("bbbb") + total := len(part1) + len(part2) + + status, err := postUpload(t, user, "/big.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{ + "X-File-Chunk-Offset": "0", + "X-File-Total-Size": strconv.Itoa(total), + }) + if status != http.StatusOK || err != nil { + t.Fatalf("chunk1 status=%d err=%v", status, err) + } + if _, err = os.Stat(filepath.Join(root, "big.bin")); !os.IsNotExist(err) { + t.Fatal("final file should not exist after first chunk") + } + + status, err = postUpload(t, user, "/big.bin", bytes.NewReader(part2), int64(len(part2)), map[string]string{ + "X-File-Chunk-Offset": strconv.Itoa(len(part1)), + "X-File-Total-Size": strconv.Itoa(total), + }) + if status != http.StatusOK || err != nil { + t.Fatalf("chunk2 status=%d err=%v", status, err) + } + got, err := os.ReadFile(filepath.Join(root, "big.bin")) + if err != nil { + t.Fatal(err) + } + want := append(append([]byte{}, part1...), part2...) + if !bytes.Equal(got, want) { + t.Fatalf("assembled mismatch: got %q want %q", got, want) + } +} + +func TestResourcePostHandler_ChunkedShortChunkContentLength(t *testing.T) { + root, user := setupUploadHTTPTest(t) + part1 := []byte("12345678") + total := 16 + + status, err := postUpload(t, user, "/short.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{ + "X-File-Chunk-Offset": "0", + "X-File-Total-Size": strconv.Itoa(total), + }) + if status != http.StatusOK || err != nil { + t.Fatalf("chunk1 status=%d err=%v", status, err) + } + + short := []byte("xx") + status, err = postUpload(t, user, "/short.bin", bytes.NewReader(short), 8, map[string]string{ + "X-File-Chunk-Offset": strconv.Itoa(len(part1)), + "X-File-Total-Size": strconv.Itoa(total), + }) + if status != http.StatusBadRequest || err == nil { + t.Fatalf("status=%d err=%v, want 400", status, err) + } + + realPath := filepath.Join(root, "short.bin") + tempPath := uploadTempPath(realPath, "test-session") + info, err := os.Stat(tempPath) + if err != nil { + t.Fatalf("expected temp preserved: %v", err) + } + if info.Size() != int64(len(part1)) { + t.Fatalf("temp size=%d, want %d", info.Size(), len(part1)) + } + if _, err := os.Stat(realPath); !os.IsNotExist(err) { + t.Fatal("final file should not exist after short chunk") + } +} + +type errAfterReader struct { + data []byte + err error + n int +} + +func (r *errAfterReader) Read(p []byte) (int, error) { + if r.n >= len(r.data) { + return 0, r.err + } + n := copy(p, r.data[r.n:]) + r.n += n + if r.n >= len(r.data) { + return n, r.err + } + return n, nil +} + +func TestResourcePostHandler_ChunkedKeepsPartialOnBodyError(t *testing.T) { + root, user := setupUploadHTTPTest(t) + part1 := []byte("aaaaaaaa") + total := 16 + + status, err := postUpload(t, user, "/resume.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{ + "X-File-Chunk-Offset": "0", + "X-File-Total-Size": strconv.Itoa(total), + }) + if status != http.StatusOK || err != nil { + t.Fatalf("chunk1 status=%d err=%v", status, err) + } + + failing := &errAfterReader{ + data: []byte("bbb"), + err: errors.New("simulated disconnect"), + } + status, err = postUpload(t, user, "/resume.bin", failing, 8, map[string]string{ + "X-File-Chunk-Offset": strconv.Itoa(len(part1)), + "X-File-Total-Size": strconv.Itoa(total), + }) + if status != http.StatusInternalServerError || err == nil { + t.Fatalf("status=%d err=%v, want 500", status, err) + } + + realPath := filepath.Join(root, "resume.bin") + tempPath := uploadTempPath(realPath, "test-session") + info, err := os.Stat(tempPath) + if err != nil { + t.Fatalf("expected temp preserved: %v", err) + } + if info.Size() != int64(len(part1)) { + t.Fatalf("temp size=%d, want truncated to %d", info.Size(), len(part1)) + } + + // Resume with a full second chunk. + part2 := []byte("bbbbbbbb") + status, err = postUpload(t, user, "/resume.bin", bytes.NewReader(part2), int64(len(part2)), map[string]string{ + "X-File-Chunk-Offset": strconv.Itoa(len(part1)), + "X-File-Total-Size": strconv.Itoa(total), + }) + if status != http.StatusOK || err != nil { + t.Fatalf("resume status=%d err=%v", status, err) + } + got, err := os.ReadFile(realPath) + if err != nil { + t.Fatal(err) + } + want := append(append([]byte{}, part1...), part2...) + if !bytes.Equal(got, want) { + t.Fatalf("resumed contents mismatch: got %q want %q", got, want) + } +} + +func TestResourcePostHandler_ChunkOffsetExceedsTotal(t *testing.T) { + _, user := setupUploadHTTPTest(t) + body := []byte("data") + status, err := postUpload(t, user, "/big.bin", bytes.NewReader(body), int64(len(body)), map[string]string{ + "X-File-Chunk-Offset": "200", + "X-File-Total-Size": "100", + }) + if status != http.StatusBadRequest || err == nil { + t.Fatalf("status=%d err=%v, want 400", status, err) + } +} + +func TestResourcePostHandler_ConflictingUploadSessions(t *testing.T) { + root, user := setupUploadHTTPTest(t) + part1 := []byte("aaaa") + total := 8 + + status, err := postUpload(t, user, "/race.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{ + "X-File-Upload-Session": "session-a", + "X-File-Chunk-Offset": "0", + "X-File-Total-Size": strconv.Itoa(total), + }) + if status != http.StatusOK || err != nil { + t.Fatalf("chunk1 status=%d err=%v", status, err) + } + + status, err = postUpload(t, user, "/race.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{ + "X-File-Upload-Session": "session-b", + "X-File-Chunk-Offset": "0", + "X-File-Total-Size": strconv.Itoa(total), + }) + if status != http.StatusConflict || err == nil { + t.Fatalf("status=%d err=%v, want 409", status, err) + } + + // Non-chunked upload should also conflict with an active chunked session. + status, err = postUpload(t, user, "/race.bin", bytes.NewReader([]byte("tiny")), 4, map[string]string{ + "X-File-Upload-Session": "session-c", + "X-File-Total-Size": "4", + }) + if status != http.StatusConflict || err == nil { + t.Fatalf("non-chunked status=%d err=%v, want 409", status, err) + } + + // Original session can still resume. + part2 := []byte("bbbb") + status, err = postUpload(t, user, "/race.bin", bytes.NewReader(part2), int64(len(part2)), map[string]string{ + "X-File-Upload-Session": "session-a", + "X-File-Chunk-Offset": strconv.Itoa(len(part1)), + "X-File-Total-Size": strconv.Itoa(total), + }) + if status != http.StatusOK || err != nil { + t.Fatalf("resume status=%d err=%v", status, err) + } + got, err := os.ReadFile(filepath.Join(root, "race.bin")) + if err != nil { + t.Fatal(err) + } + want := append(append([]byte{}, part1...), part2...) + if !bytes.Equal(got, want) { + t.Fatalf("assembled mismatch: got %q want %q", got, want) + } +} + +func TestResourcePostHandler_ExpiredSessionAllowsNewUpload(t *testing.T) { + root, user := setupUploadHTTPTest(t) + part1 := []byte("aaaa") + total := 8 + realPath := filepath.Join(root, "stale.bin") + + status, err := postUpload(t, user, "/stale.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{ + "X-File-Upload-Session": "session-old", + "X-File-Chunk-Offset": "0", + "X-File-Total-Size": strconv.Itoa(total), + }) + if status != http.StatusOK || err != nil { + t.Fatalf("chunk1 status=%d err=%v", status, err) + } + + // Abandoned session still blocks until the lease expires. + status, err = postUpload(t, user, "/stale.bin", bytes.NewReader([]byte("fullbody")), 8, map[string]string{ + "X-File-Upload-Session": "session-new", + "X-File-Total-Size": "8", + }) + if status != http.StatusConflict || err == nil { + t.Fatalf("status=%d err=%v, want 409 while lease active", status, err) + } + + activeUploadSessions.expireForTest(realPath) + + body := []byte("replaced!") + status, err = postUpload(t, user, "/stale.bin", bytes.NewReader(body), int64(len(body)), map[string]string{ + "X-File-Upload-Session": "session-new", + "X-File-Total-Size": strconv.Itoa(len(body)), + }) + if status != http.StatusOK || err != nil { + t.Fatalf("after expiry status=%d err=%v", status, err) + } + got, err := os.ReadFile(realPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, body) { + t.Fatalf("got %q want %q", got, body) + } +} diff --git a/backend/internal/web/share.go b/backend/internal/web/share.go index da7fb4c7..0046f736 100644 --- a/backend/internal/web/share.go +++ b/backend/internal/web/share.go @@ -280,6 +280,9 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, if getErr != nil { return http.StatusBadRequest, fmt.Errorf("share not found") } + if !beforeShare.UserCanEdit(d.User) { + return http.StatusForbidden, fmt.Errorf("you are not allowed to update this share") + } updateSourceName := beforeShare.GetSourceName() if updateSourceName != "" { ownerPerms, permErr := d.User.FilePermsForSourceName(updateSourceName) @@ -307,7 +310,6 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, link.SourcePath = preservedSourcePath link.PinnedItems = preservedPinned link.Version = preservedVersion - link.UserID = d.User.ID if link.ShareType == "upload" && !req.AllowCreate { link.AllowCreate = true } diff --git a/backend/internal/web/share_auth_test.go b/backend/internal/web/share_auth_test.go new file mode 100644 index 00000000..488e4ef2 --- /dev/null +++ b/backend/internal/web/share_auth_test.go @@ -0,0 +1,186 @@ +package web + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gtsteffaniak/filebrowser/backend/internal/database/share" + "github.com/gtsteffaniak/filebrowser/backend/internal/database/users" + "github.com/gtsteffaniak/filebrowser/backend/internal/state" +) + +const victimShareHash = "victim_hash_test_01" + +func setupShareAuthTestUsers(t *testing.T) (owner, attacker, admin *users.User) { + t.Helper() + setupTestEnv(t) + + fullPerms := users.SourceFilePermissions{ + View: true, Download: true, Modify: true, Create: true, Delete: true, + } + shareScope := users.BackendScope{Path: "/srv", Scope: "/", Permissions: fullPerms} + backendPerms := map[string]users.SourceFilePermissions{"/srv": fullPerms} + + owner = &users.User{ + FrontendUser: users.FrontendUser{ + Username: "share_owner", + Permissions: users.Permissions{Share: true}, + }, + BackendScopes: []users.BackendScope{shareScope}, + BackendSourcePermissions: backendPerms, + Version: users.SourcePermissionsMigrationVersion, + } + attacker = &users.User{ + FrontendUser: users.FrontendUser{ + Username: "share_attacker", + Permissions: users.Permissions{Share: true}, + }, + BackendScopes: []users.BackendScope{shareScope}, + BackendSourcePermissions: backendPerms, + Version: users.SourcePermissionsMigrationVersion, + } + admin = &users.User{ + FrontendUser: users.FrontendUser{ + Username: "share_admin", + Permissions: users.Permissions{Admin: true, Share: true}, + }, + BackendScopes: []users.BackendScope{shareScope}, + BackendSourcePermissions: backendPerms, + Version: users.SourcePermissionsMigrationVersion, + } + + for _, u := range []*users.User{owner, attacker, admin} { + if err := state.CreateUser(u, ""); err != nil { + t.Fatalf("CreateUser(%s): %v", u.Username, err) + } + } + adminFetched, err := state.GetUserByUsername("share_admin") + if err != nil { + t.Fatalf("GetUserByUsername(share_admin): %v", err) + } + if err := state.UpdateUser(&users.User{ + ID: adminFetched.ID, + FrontendUser: users.FrontendUser{ + Permissions: users.Permissions{Admin: true, Share: true}, + }, + }, "", "permissions"); err != nil { + t.Fatalf("set admin permissions: %v", err) + } + + for _, pair := range []struct { + name string + ptr **users.User + }{ + {"share_owner", &owner}, + {"share_attacker", &attacker}, + {"share_admin", &admin}, + } { + got, err := state.GetUserByUsername(pair.name) + if err != nil { + t.Fatalf("GetUserByUsername(%s): %v", pair.name, err) + } + *pair.ptr = &got + } + + return owner, attacker, admin +} + +func createVictimShare(t *testing.T, ownerID uint64) { + t.Helper() + victim := &share.Share{ + ShareSettings: share.ShareSettings{ + FrontendShareInfo: share.FrontendShareInfo{ShareType: "normal"}, + ShareLimits: share.ShareLimits{SourceName: "srv"}, + }, + ShareColumns: share.ShareColumns{ + Hash: victimShareHash, + Path: "/", + }, + SourcePath: "/srv", + UserID: ownerID, + Version: 1, + } + if err := state.CreateShare(victim); err != nil { + t.Fatalf("CreateShare: %v", err) + } +} + +func postShareUpdate(t *testing.T, user *users.User, hash string, allowDelete bool) (int, error) { + t.Helper() + raw, err := json.Marshal(map[string]interface{}{ + "hash": hash, + "allowDelete": allowDelete, + }) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/share", bytes.NewReader(raw)) + rec := httptest.NewRecorder() + return sharePostHandler(rec, req, &Context{User: user}) +} + +func TestSharePostUpdate_RejectNonOwner(t *testing.T) { + owner, attacker, _ := setupShareAuthTestUsers(t) + createVictimShare(t, owner.ID) + + status, err := postShareUpdate(t, attacker, victimShareHash, true) + if status != http.StatusForbidden { + t.Fatalf("expected 403, got status=%d err=%v", status, err) + } + + got, err := state.GetShare(victimShareHash) + if err != nil { + t.Fatalf("GetShare: %v", err) + } + if got.AllowDelete { + t.Fatal("non-owner update should not enable allowDelete") + } + if got.UserID != owner.ID { + t.Fatalf("UserID changed to %d, want owner %d", got.UserID, owner.ID) + } +} + +func TestSharePostUpdate_AllowsOwner(t *testing.T) { + owner, _, _ := setupShareAuthTestUsers(t) + createVictimShare(t, owner.ID) + + status, err := postShareUpdate(t, owner, victimShareHash, true) + if status != http.StatusOK { + t.Fatalf("expected 200, got status=%d err=%v", status, err) + } + + got, err := state.GetShare(victimShareHash) + if err != nil { + t.Fatalf("GetShare: %v", err) + } + if !got.AllowDelete { + t.Fatal("owner update should enable allowDelete") + } + if got.UserID != owner.ID { + t.Fatalf("UserID changed to %d, want owner %d", got.UserID, owner.ID) + } +} + +func TestSharePostUpdate_AdminCanEdit(t *testing.T) { + owner, _, admin := setupShareAuthTestUsers(t) + createVictimShare(t, owner.ID) + + status, err := postShareUpdate(t, admin, victimShareHash, true) + if status != http.StatusOK { + t.Fatalf("expected 200, got status=%d err=%v", status, err) + } + + got, err := state.GetShare(victimShareHash) + if err != nil { + t.Fatalf("GetShare: %v", err) + } + if !got.AllowDelete { + t.Fatal("admin update should enable allowDelete") + } + if got.UserID != owner.ID { + t.Fatalf("admin update should not reassign UserID: got %d want %d", got.UserID, owner.ID) + } +} diff --git a/backend/internal/web/stream.go b/backend/internal/web/stream.go index fc12481f..c89e3989 100644 --- a/backend/internal/web/stream.go +++ b/backend/internal/web/stream.go @@ -14,6 +14,7 @@ import ( "time" "github.com/gtsteffaniak/filebrowser/backend/internal/state" + liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors" "github.com/gtsteffaniak/filebrowser/backend/internal/utils" "github.com/gtsteffaniak/filebrowser/backend/pkg/indexing" "github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo" @@ -433,8 +434,25 @@ func ServeSingleFile(w http.ResponseWriter, r *http.Request, d *Context, source return http.StatusForbidden, fmt.Errorf("access denied to path %s", scopedFilePath) } - realPath, _, err := idx.GetRealPath(scopedFilePath) + var bound string + if d.Share.Hash != "" { + if d.Share.Path == "" { + return http.StatusForbidden, fmt.Errorf("share has no path scope") + } + bound = d.Share.Path + } else { + userScope, scopeErr := d.User.GetScopeForSourceName(source) + if scopeErr != nil || userScope == "" { + return http.StatusForbidden, fmt.Errorf("user has no access to source: %s", source) + } + bound = userScope + } + + realPath, _, err := idx.GetRealPathScoped(bound, scopedFilePath) if err != nil { + if errors.Is(err, liberrors.ErrPathEscapesScope) { + return http.StatusForbidden, err + } return http.StatusInternalServerError, err } @@ -553,7 +571,7 @@ func streamHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err if err != nil { return http.StatusForbidden, err } - scopedPath := utils.JoinPathAsUnix(userscope, cleanPath) + scopedPath := utils.JoinScopedIndexPath(userscope, cleanPath) return streamFilesHandler(w, r, d, source, []string{scopedPath}) } @@ -600,7 +618,7 @@ func publicStreamHandler(w http.ResponseWriter, r *http.Request, d *Context) (in if !IsMediaStreamFile(shareRelativeDisplayName(d, cleanFile)) { return http.StatusForbidden, fmt.Errorf("stream endpoint supports audio and video only") } - scopedPath := utils.JoinPathAsUnix(d.Share.Path, cleanFile) + scopedPath := utils.JoinScopedIndexPath(d.Share.Path, cleanFile) status, err := streamFilesHandler(w, r, d, sourceInfo.Name, []string{scopedPath}) if err != nil { if status == http.StatusForbidden { diff --git a/backend/internal/web/upload_session.go b/backend/internal/web/upload_session.go new file mode 100644 index 00000000..940e5f53 --- /dev/null +++ b/backend/internal/web/upload_session.go @@ -0,0 +1,88 @@ +package web + +import ( + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +const uploadSessionHeader = "X-File-Upload-Session" + +// uploadSessionTTL is how long an incomplete upload may keep exclusive claim on a path. +// Successful chunks refresh the lease; abandoned sessions expire and can be replaced. +var uploadSessionTTL = 30 * time.Minute + +var errUploadSessionConflict = errors.New("conflicting upload session") + +type uploadSessionEntry struct { + id string + lastSeen time.Time +} + +type uploadSessionRegistry struct { + mu sync.Mutex + sessions map[string]uploadSessionEntry // realPath -> active session +} + +var activeUploadSessions uploadSessionRegistry + +func (r *uploadSessionRegistry) acquire(realPath, sessionID string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.sessions == nil { + r.sessions = make(map[string]uploadSessionEntry) + } + now := time.Now() + if active, ok := r.sessions[realPath]; ok { + if now.Sub(active.lastSeen) > uploadSessionTTL { + delete(r.sessions, realPath) + } else if active.id != sessionID { + return fmt.Errorf("%w for %q", errUploadSessionConflict, realPath) + } + } + r.sessions[realPath] = uploadSessionEntry{id: sessionID, lastSeen: now} + return nil +} + +func (r *uploadSessionRegistry) release(realPath, sessionID string) { + r.mu.Lock() + defer r.mu.Unlock() + if active, ok := r.sessions[realPath]; ok && active.id == sessionID { + delete(r.sessions, realPath) + } +} + +// expireForTest marks a path's session as expired without deleting it (for unit tests). +func (r *uploadSessionRegistry) expireForTest(realPath string) { + r.mu.Lock() + defer r.mu.Unlock() + if active, ok := r.sessions[realPath]; ok { + active.lastSeen = time.Now().Add(-uploadSessionTTL - time.Second) + r.sessions[realPath] = active + } +} + +func isUploadSessionConflict(err error) bool { + return errors.Is(err, errUploadSessionConflict) +} + +// parseUploadSession reads X-File-Upload-Session. The value must be a short safe token. +func parseUploadSession(r *http.Request) (string, error) { + s := strings.TrimSpace(r.Header.Get(uploadSessionHeader)) + if s == "" { + return "", fmt.Errorf("missing %s", uploadSessionHeader) + } + if len(s) > 128 { + return "", fmt.Errorf("invalid upload session") + } + for _, c := range s { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' { + continue + } + return "", fmt.Errorf("invalid upload session") + } + return s, nil +} diff --git a/backend/internal/web/upload_validate.go b/backend/internal/web/upload_validate.go new file mode 100644 index 00000000..27ac1cf3 --- /dev/null +++ b/backend/internal/web/upload_validate.go @@ -0,0 +1,98 @@ +package web + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "math" + "net/http" + "strconv" +) + +// uploadTempPath returns the temporary path used while assembling an upload for realPath. +// sessionID isolates concurrent uploads targeting the same destination. +func uploadTempPath(realPath, sessionID string) string { + hasher := md5.New() + hasher.Write([]byte(realPath)) + hasher.Write([]byte{0}) + hasher.Write([]byte(sessionID)) + return fmt.Sprintf("%s.%s.uploading.tmp", realPath, hex.EncodeToString(hasher.Sum(nil))) +} + +// parseUploadTotalSize reads optional X-File-Total-Size. +// ok is false when the header is absent. +func parseUploadTotalSize(r *http.Request) (total int64, ok bool, err error) { + s := r.Header.Get("X-File-Total-Size") + if s == "" { + return 0, false, nil + } + total, err = strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, false, fmt.Errorf("invalid total size: %v", err) + } + if total < 0 { + return 0, false, fmt.Errorf("invalid total size: %d", total) + } + return total, true, nil +} + +// validateChunkBounds rejects invalid offsets and sizes before writing a chunk. +// contentLength is the request Content-Length when known (>0). +func validateChunkBounds(offset, contentLength, totalSize int64) error { + if offset < 0 { + return fmt.Errorf("invalid chunk offset: %d", offset) + } + if totalSize < 0 { + return fmt.Errorf("invalid total size: %d", totalSize) + } + if offset > totalSize { + return fmt.Errorf("chunk offset exceeds total size: offset %d > %d", offset, totalSize) + } + remaining := totalSize - offset + if contentLength > 0 { + if contentLength > remaining { + return fmt.Errorf("chunk exceeds total size: offset %d + %d > %d", offset, contentLength, totalSize) + } + if offset > math.MaxInt64-contentLength { + return fmt.Errorf("chunk offset overflow") + } + } + return nil +} + +// validateAssembledSize checks written chunk bytes against the declared total without overflow. +func validateAssembledSize(offset, chunkSize, totalSize int64) error { + if chunkSize < 0 { + return fmt.Errorf("invalid chunk size: %d", chunkSize) + } + if err := validateChunkBounds(offset, chunkSize, totalSize); err != nil { + return err + } + return nil +} + +// validateReceivedBytes compares bytes written to optional declared totals. +// expectedTotal is checked only when hasExpected is true. +// contentLength is checked when greater than 0 (known Content-Length). +func validateReceivedBytes(written, expectedTotal int64, hasExpected bool, contentLength int64) error { + if hasExpected && written != expectedTotal { + return fmt.Errorf("upload incomplete: expected %d bytes, received %d", expectedTotal, written) + } + if contentLength > 0 && written != contentLength { + return fmt.Errorf("upload incomplete: expected %d bytes, received %d", contentLength, written) + } + return nil +} + +// isContentUpload reports whether the request is uploading file bytes (as opposed to +// creating an empty file via POST with no upload metadata). +func isContentUpload(r *http.Request) bool { + if r.Header.Get("X-File-Chunk-Offset") != "" { + return true + } + if r.Header.Get(uploadSessionHeader) != "" { + return true + } + _, ok, err := parseUploadTotalSize(r) + return err == nil && ok +} diff --git a/backend/internal/web/users.go b/backend/internal/web/users.go index 97811013..eeeea1f7 100644 --- a/backend/internal/web/users.go +++ b/backend/internal/web/users.go @@ -43,9 +43,6 @@ type UserRequest struct { // @Failure 500 {object} map[string]string "Internal Server Error" // @Router /api/users [get] func userGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) { - // since api self is used to validate a logged in user - w.Header().Add("X-Renew-Token", "false") - if strings.TrimSpace(r.URL.Query().Get("id")) != "" { return http.StatusBadRequest, fmt.Errorf("query parameter id is not supported; use username=self for the current user or username=") } diff --git a/backend/internal/web/view.go b/backend/internal/web/view.go index 925b83b4..f0133997 100644 --- a/backend/internal/web/view.go +++ b/backend/internal/web/view.go @@ -63,7 +63,7 @@ func viewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error if err != nil { return http.StatusForbidden, err } - scopedPath := utils.JoinPathAsUnix(userscope, cleanPath) + scopedPath := utils.JoinScopedIndexPath(userscope, cleanPath) return viewFilesHandler(w, r, d, source, []string{scopedPath}) } @@ -107,7 +107,7 @@ func PublicViewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, if err = ValidateViewGrant(token, d, ""); err != nil { return http.StatusForbidden, err } - scopedPath := utils.JoinPathAsUnix(d.Share.Path, cleanFile) + scopedPath := utils.JoinScopedIndexPath(d.Share.Path, cleanFile) status, err := viewFilesHandler(w, r, d, sourceInfo.Name, []string{scopedPath}) if err != nil { if status == http.StatusForbidden { diff --git a/backend/main.go b/backend/main.go index 77172ca2..d195e844 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,9 +1,11 @@ package main import ( + "github.com/golang-jwt/jwt/v4" "github.com/gtsteffaniak/filebrowser/backend/cmd" ) func main() { + jwt.DecodeStrict = true cmd.StartFilebrowser() } diff --git a/backend/pkg/indexing/indexingFiles.go b/backend/pkg/indexing/indexingFiles.go index 95dddc42..0e76c14f 100644 --- a/backend/pkg/indexing/indexingFiles.go +++ b/backend/pkg/indexing/indexingFiles.go @@ -428,6 +428,7 @@ type PathContext struct { type FileInfoRequest struct { IndexPath string FollowSymlinks bool + BoundIndexPath string // index prefix for symlink containment (e.g. user scope) ShowHidden bool HideFileExt string // Hide files based on their extension Expand bool // get child items for directories @@ -435,10 +436,64 @@ type FileInfoRequest struct { SkipExtendedAttrs bool // whether to skip extended attributes } +// filesystemBound resolves the filesystem path for an index prefix under this source. +func (idx *Index) filesystemBound(boundIndexPath string) (string, error) { + bound := boundIndexPath + if bound == "" { + bound = "/" + } + fsPath := idx.Path + if bound != "/" { + fsPath = utils.JoinUnderSourceRoot(idx.Path, bound) + } + abs, err := filepath.Abs(fsPath) + if err != nil { + return "", err + } + return filepath.EvalSymlinks(abs) +} + +// ensureResolvedContained rejects resolved filesystem paths outside the source or scope prefix. +func (idx *Index) ensureResolvedContained(realPath, boundIndexPath string) error { + sourceRoot, err := idx.filesystemBound("/") + if err != nil { + return err + } + if err := iteminfo.PathWithinRoot(sourceRoot, realPath); err != nil { + return errors.ErrPathEscapesScope + } + bound := boundIndexPath + if bound == "" { + bound = "/" + } + if bound != "/" { + scopeRoot, err := idx.filesystemBound(bound) + if err != nil { + return err + } + if err := iteminfo.PathWithinRoot(scopeRoot, realPath); err != nil { + return errors.ErrPathEscapesScope + } + } + return nil +} + +// resolveSymlinksContained resolves symlinks and ensures the target stays within bounds. +func (idx *Index) resolveSymlinksContained(fsPath, boundIndexPath string) (string, error) { + resolved, err := filepath.EvalSymlinks(fsPath) + if err != nil { + return "", err + } + if err := idx.ensureResolvedContained(resolved, boundIndexPath); err != nil { + return "", err + } + return resolved, nil +} + // resolvePathContext resolves all path characteristics in a SINGLE stat call // This eliminates duplicate stat/lstat calls and redundant isHidden/isSymlink checks -func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*PathContext, error) { - realPath := filepath.Join(idx.Path, indexPath) +func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool, boundIndexPath string) (*PathContext, error) { + realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath) // ONE filesystem stat call fileInfo, err := os.Lstat(realPath) @@ -450,7 +505,7 @@ func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*Pa // Resolve symlink if requested if isSymlink && followSymlinks { - realPath, err = filepath.EvalSymlinks(realPath) + realPath, err = idx.resolveSymlinksContained(realPath, boundIndexPath) if err != nil { return nil, err } @@ -506,7 +561,7 @@ func (idx *Index) evaluateIndexRules(ctx *PathContext, isRoutineScan bool) (isVi // User permission checking happens in the API layer (FileInfoFaster) func (idx *Index) GetFileInfo(req FileInfoRequest) (*iteminfo.FileInfo, error) { // 1. Resolve path context (single stat call) - ctx, err := idx.resolvePathContext(req.IndexPath, req.FollowSymlinks) + ctx, err := idx.resolvePathContext(req.IndexPath, req.FollowSymlinks, req.BoundIndexPath) if err != nil { return nil, err } @@ -594,7 +649,7 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne if !strings.HasSuffix(indexPath, "/") { indexPath = indexPath + "/" } - realPath := filepath.Join(idx.Path, indexPath) + realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath) // Open the directory dir, err := os.Open(realPath) @@ -632,10 +687,10 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne // GetFsInfoCore is the consolidated implementation for both GetFsInfo and GetFsInfoViewableOnly func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileInfo, error) { // Handle symlinks if not following them - realPath := filepath.Join(idx.Path, indexPath) + realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath) if opts.FollowSymlinks { var err error - realPath, err = filepath.EvalSymlinks(realPath) + realPath, err = idx.resolveSymlinksContained(realPath, "/") if err != nil { return nil, err } @@ -991,10 +1046,28 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, indexPath strin } func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) { - combined := append([]string{idx.Path}, relativePath...) - joinedPath := filepath.Join(combined...) - isDir, _ := IsDirCache.Get(joinedPath + ":isdir") - cached, ok := RealPathCache.Get(joinedPath) + return idx.getRealPathInternal("/", false, relativePath...) +} + +// GetRealPathScoped resolves an index path and rejects symlink targets outside the source and bound prefix. +func (idx *Index) GetRealPathScoped(boundIndexPath string, relativePath ...string) (string, bool, error) { + return idx.getRealPathInternal(boundIndexPath, true, relativePath...) +} + +func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool, relativePath ...string) (string, bool, error) { + parts := make([]string, 0, len(relativePath)+1) + parts = append(parts, idx.Path) + for _, p := range relativePath { + parts = append(parts, utils.CleanIndexPathSegment(p)) + } + joinedPath := filepath.Join(parts...) + bound := boundIndexPath + if enforceScope && bound == "" { + bound = "/" + } + cacheKey := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", enforceScope, bound, joinedPath) + isDir, _ := IsDirCache.Get(cacheKey + ":isdir") + cached, ok := RealPathCache.Get(cacheKey) if ok && cached != "" { return cached, isDir, nil } @@ -1003,11 +1076,19 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) { return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err) } realPath, isDir, err := iteminfo.ResolveSymlinks(absolutePath) - if err == nil { - RealPathCache.Set(joinedPath, realPath) - IsDirCache.Set(joinedPath+":isdir", isDir) + if err != nil { + return realPath, isDir, err } - return realPath, isDir, err + if enforceScope { + if err := idx.ensureResolvedContained(realPath, boundIndexPath); err != nil { + return "", false, err + } + } else if err := idx.ensureResolvedContained(realPath, "/"); err != nil { + return "", false, err + } + RealPathCache.Set(cacheKey, realPath) + IsDirCache.Set(cacheKey+":isdir", isDir) + return realPath, isDir, nil } func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error { @@ -1034,7 +1115,7 @@ func (idx *Index) RefreshDirectory(indexPath string, recursive bool) error { indexPath = indexPath + "/" } - realPath := filepath.Join(idx.Path, indexPath) + realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath) // Check if directory exists dirInfo, err := os.Stat(realPath) diff --git a/backend/pkg/indexing/iteminfo/utils.go b/backend/pkg/indexing/iteminfo/utils.go index f43acc1a..68046998 100644 --- a/backend/pkg/indexing/iteminfo/utils.go +++ b/backend/pkg/indexing/iteminfo/utils.go @@ -105,6 +105,35 @@ func (info *FileInfo) SortItems() { }) } +// PathWithinRoot reports whether path is equal to or under root after resolving symlinks. +func PathWithinRoot(root, path string) error { + rootAbs, err := filepath.Abs(root) + if err != nil { + return fmt.Errorf("invalid root path: %w", err) + } + rootReal, err := filepath.EvalSymlinks(rootAbs) + if err != nil { + return fmt.Errorf("could not resolve root path: %w", err) + } + pathAbs, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("invalid path: %w", err) + } + pathReal, err := filepath.EvalSymlinks(pathAbs) + if err != nil { + return fmt.Errorf("could not resolve path: %w", err) + } + rel, err := filepath.Rel(rootReal, pathReal) + if err != nil { + return fmt.Errorf("path outside root: %w", err) + } + sep := string(filepath.Separator) + if rel == ".." || strings.HasPrefix(rel, ".."+sep) { + return fmt.Errorf("path escapes root") + } + return nil +} + // ResolveSymlinks resolves symlinks in the given path and returns // Uses Go's filepath.EvalSymlinks which properly detects circular symlinks. func ResolveSymlinks(path string) (string, bool, error) { diff --git a/backend/pkg/indexing/symlink_containment_test.go b/backend/pkg/indexing/symlink_containment_test.go new file mode 100644 index 00000000..fa39d0dc --- /dev/null +++ b/backend/pkg/indexing/symlink_containment_test.go @@ -0,0 +1,178 @@ +package indexing + +import ( + "errors" + "os" + "path/filepath" + "testing" + + liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors" + "github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo" + "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" +) + +func setupSymlinkEscapeFixture(t *testing.T) (sourceRoot, userScope string) { + t.Helper() + root := t.TempDir() + sourceRoot = filepath.Join(root, "srv") + userDir := filepath.Join(sourceRoot, "user1") + if err := os.MkdirAll(userDir, 0o755); err != nil { + t.Fatal(err) + } + outsideSecret := filepath.Join(root, "secret-outside.txt") + if err := os.WriteFile(outsideSecret, []byte("ROOT_DB_PASSWORD=supersecret"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outsideSecret, filepath.Join(userDir, "secret_link")); err != nil { + t.Fatal(err) + } + sibling := filepath.Join(root, "outside-sibling.txt") + if err := os.WriteFile(sibling, []byte("outside sibling"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(sibling, filepath.Join(userDir, "sibling_link")); err != nil { + t.Fatal(err) + } + inside := filepath.Join(userDir, "allowed.txt") + if err := os.WriteFile(inside, []byte("allowed"), 0o644); err != nil { + t.Fatal(err) + } + return sourceRoot, "/user1" +} + +func symlinkTestIndex(sourceRoot string) *Index { + return &Index{Source: settings.Source{Path: sourceRoot}} +} + +func TestPathWithinRoot(t *testing.T) { + root := t.TempDir() + inside := filepath.Join(root, "inner", "file.txt") + if err := os.MkdirAll(filepath.Dir(inside), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(inside, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + outside := filepath.Join(filepath.Dir(root), "outside.txt") + if err := os.WriteFile(outside, []byte("y"), 0o644); err != nil { + t.Fatal(err) + } + if err := iteminfo.PathWithinRoot(root, inside); err != nil { + t.Fatalf("expected inside path to be allowed: %v", err) + } + if err := iteminfo.PathWithinRoot(root, outside); err == nil { + t.Fatal("expected outside path to be rejected") + } +} + +func TestGetRealPath_RejectsEscapeOutsideSource(t *testing.T) { + sourceRoot, _ := setupSymlinkEscapeFixture(t) + idx := symlinkTestIndex(sourceRoot) + + _, _, err := idx.GetRealPath("/user1/secret_link") + if !errors.Is(err, liberrors.ErrPathEscapesScope) { + t.Fatalf("expected ErrPathEscapesScope, got %v", err) + } + _, _, err = idx.GetRealPath("/user1/sibling_link") + if !errors.Is(err, liberrors.ErrPathEscapesScope) { + t.Fatalf("expected ErrPathEscapesScope for sibling link, got %v", err) + } +} + +func TestGetRealPathScoped_RejectsEscapeOutsideUserScope(t *testing.T) { + sourceRoot, userScope := setupSymlinkEscapeFixture(t) + idx := symlinkTestIndex(sourceRoot) + + // Under source but outside /user1: place secret inside source root + insideSource := filepath.Join(sourceRoot, "secret-in-source.txt") + if err := os.WriteFile(insideSource, []byte("in source"), 0o644); err != nil { + t.Fatal(err) + } + userDir := filepath.Join(sourceRoot, "user1") + if err := os.Symlink(insideSource, filepath.Join(userDir, "in_source_link")); err != nil { + t.Fatal(err) + } + + _, _, err := idx.GetRealPathScoped(userScope, "/user1/in_source_link") + if !errors.Is(err, liberrors.ErrPathEscapesScope) { + t.Fatalf("expected ErrPathEscapesScope for in-source escape, got %v", err) + } +} + +func TestGetRealPathScoped_AllowsInScopeTarget(t *testing.T) { + sourceRoot, userScope := setupSymlinkEscapeFixture(t) + idx := symlinkTestIndex(sourceRoot) + + userDir := filepath.Join(sourceRoot, "user1") + if err := os.Symlink("allowed.txt", filepath.Join(userDir, "good_link")); err != nil { + t.Fatal(err) + } + + realPath, _, err := idx.GetRealPathScoped(userScope, "/user1/good_link") + if err != nil { + t.Fatalf("expected in-scope symlink to resolve: %v", err) + } + want := filepath.Join(userDir, "allowed.txt") + realPath, err = filepath.EvalSymlinks(realPath) + if err != nil { + t.Fatal(err) + } + want, err = filepath.EvalSymlinks(want) + if err != nil { + t.Fatal(err) + } + if realPath != want { + t.Fatalf("got real path %q, want %q", realPath, want) + } +} + +func TestGetRealPathScoped_CacheKeyDoesNotCollideWithUnscoped(t *testing.T) { + sourceRoot, userScope := setupSymlinkEscapeFixture(t) + idx := symlinkTestIndex(sourceRoot) + + insideSource := filepath.Join(sourceRoot, "secret-in-source.txt") + if err := os.WriteFile(insideSource, []byte("in source"), 0o644); err != nil { + t.Fatal(err) + } + userDir := filepath.Join(sourceRoot, "user1") + if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("legitimate"), 0o644); err != nil { + t.Fatal(err) + } + + // Legacy scoped cache keys used joinedPath + ":bound:" + bound, which could collide + // with an unscoped joinedPath when ":bound:" appeared in a path component. + legacyKey := filepath.Join(sourceRoot, "user1", "foo") + ":bound:" + "/user1" + RealPathCache.Set(legacyKey, insideSource) + IsDirCache.Set(legacyKey+":isdir", false) + + realPath, _, err := idx.GetRealPathScoped(userScope, "/user1/foo") + if err != nil { + t.Fatalf("scoped in-scope file should resolve: %v", err) + } + want := filepath.Join(userDir, "foo") + want, err = filepath.EvalSymlinks(want) + if err != nil { + t.Fatal(err) + } + realPath, err = filepath.EvalSymlinks(realPath) + if err != nil { + t.Fatal(err) + } + if realPath != want { + t.Fatalf("scoped lookup returned %q, want in-scope file %q", realPath, want) + } +} + +func TestGetFileInfo_RejectsSymlinkEscapeWithBound(t *testing.T) { + sourceRoot, userScope := setupSymlinkEscapeFixture(t) + idx := symlinkTestIndex(sourceRoot) + + _, err := idx.GetFileInfo(FileInfoRequest{ + IndexPath: "/user1/secret_link", + FollowSymlinks: true, + BoundIndexPath: userScope, + }) + if !errors.Is(err, liberrors.ErrPathEscapesScope) { + t.Fatalf("expected ErrPathEscapesScope from GetFileInfo, got %v", err) + } +} diff --git a/backend/pkg/indexing/unix.go b/backend/pkg/indexing/unix.go index 44d4f86f..853856e1 100644 --- a/backend/pkg/indexing/unix.go +++ b/backend/pkg/indexing/unix.go @@ -4,10 +4,10 @@ package indexing import ( "os" - "path/filepath" "strings" "syscall" + "github.com/gtsteffaniak/filebrowser/backend/internal/utils" "github.com/gtsteffaniak/go-logger/logger" ) @@ -66,7 +66,7 @@ func (idx *Index) handleFile(file os.FileInfo, indexPath string, realFilePath st // Use provided realFilePath if available, otherwise construct it if realFilePath == "" { - realFilePath = filepath.Join(idx.Path, indexPath) + realFilePath = utils.JoinUnderSourceRoot(idx.Path, indexPath) } sys := file.Sys() diff --git a/backend/pkg/settings/settings.go b/backend/pkg/settings/settings.go index 74221f46..853335bf 100644 --- a/backend/pkg/settings/settings.go +++ b/backend/pkg/settings/settings.go @@ -71,6 +71,49 @@ func AdminSourceFilePermissions() users.SourceFilePermissions { } } +// MergeDefaultEnabledBackendScopes ensures every defaultEnabled source is present in +// BackendScopes. Existing scopes for configured sources are preserved (empty Scope is +// filled from DefaultUserScope). Unknown paths not in Config.Server.Sources are kept +// at the end. defaultEnabled: false sources are never auto-added. +func MergeDefaultEnabledBackendScopes(existing []users.BackendScope) []users.BackendScope { + byPath := make(map[string]users.BackendScope, len(existing)) + for _, s := range existing { + byPath[s.Path] = s + } + + newScopes := make([]users.BackendScope, 0, len(existing)+len(Config.Server.Sources)) + seen := make(map[string]struct{}, len(Config.Server.Sources)) + for _, src := range Config.Server.Sources { + if src == nil { + continue + } + existingScope, ok := byPath[src.Path] + if ok { + if existingScope.Scope == "" { + existingScope.Scope = src.Config.DefaultUserScope + } + } else if src.Config.DefaultEnabled { + existingScope = users.BackendScope{ + Scope: src.Config.DefaultUserScope, + } + } else { + continue + } + newScopes = append(newScopes, users.BackendScope{ + Path: src.Path, + Scope: existingScope.Scope, + Permissions: existingScope.Permissions, + }) + seen[src.Path] = struct{}{} + } + for _, s := range existing { + if _, ok := seen[s.Path]; !ok { + newScopes = append(newScopes, s) + } + } + return newScopes +} + // ApplyUserDefaults applies Config.UserDefaults to a user (tests and legacy callers). func ApplyUserDefaults(u *users.User) { ApplyUserDefaultsFrom(u, Config.UserDefaults) @@ -94,24 +137,20 @@ func ApplyUserDefaultsFrom(u *users.User, d UserDefaults) { sourceDefaults := DefaultSourceFilePermissions() - if len(u.BackendScopes) == 0 { - for _, source := range Config.Server.Sources { - if source.Config.DefaultEnabled { - u.BackendScopes = append(u.BackendScopes, users.BackendScope{ - Path: source.Path, - Scope: source.Config.DefaultUserScope, - }) - if len(u.SidebarLinks) == 0 { - u.SidebarLinks = append(u.SidebarLinks, users.SidebarLink{ - Name: source.Name, - Category: "source", - Target: "/", - Icon: "", - SourceName: source.Path, - }) - } - } + 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: "", + SourceName: scope.Path, + }) } if u.BackendSourcePermissions == nil { diff --git a/backend/scripts/setup-gofitz-cgo/main.go b/backend/scripts/setup-gofitz-cgo/main.go new file mode 100644 index 00000000..1958923f --- /dev/null +++ b/backend/scripts/setup-gofitz-cgo/main.go @@ -0,0 +1,168 @@ +// Setup go-fitz MuPDF headers for preview CGO (-tags=mupdf). +// Run from the backend module root: go run ./scripts/setup-gofitz-cgo +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +func main() { + backendDir, err := backendRoot() + if err != nil { + fatal(err) + } + + outPath := filepath.Join(backendDir, "internal", "preview", "gofitzinclude") + includeDir, err := goFitzIncludeDir(backendDir) + if err != nil { + fatal(err) + } + + if err := removeExisting(outPath); err != nil { + fatal(err) + } + if err := linkInclude(includeDir, outPath); err != nil { + fatal(err) + } + + fmt.Println("linked go-fitz MuPDF headers at", outPath) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} + +func backendRoot() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", err + } + if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil { + return wd, nil + } + backend := filepath.Join(wd, "backend") + if _, err := os.Stat(filepath.Join(backend, "go.mod")); err == nil { + return backend, nil + } + return "", fmt.Errorf("could not find backend module root from %s", wd) +} + +func goFitzIncludeDir(backendDir string) (string, error) { + cmd := exec.Command("go", "mod", "download", "-json", "github.com/gen2brain/go-fitz") + cmd.Dir = backendDir + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("go mod download go-fitz: %w: %s", err, ee.Stderr) + } + return "", fmt.Errorf("go mod download go-fitz: %w", err) + } + + var modInfo struct { + Dir string `json:"Dir"` + } + if err := json.Unmarshal(out, &modInfo); err != nil { + return "", fmt.Errorf("parse go mod download output: %w", err) + } + if modInfo.Dir == "" { + return "", fmt.Errorf("go-fitz module path not found after download") + } + + include := filepath.Join(modInfo.Dir, "include") + if fi, err := os.Stat(include); err != nil || !fi.IsDir() { + return "", fmt.Errorf("go-fitz include directory not found: %s", include) + } + return include, nil +} + +func removeExisting(path string) error { + fi, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if fi.Mode()&os.ModeSymlink != 0 { + return os.Remove(path) + } + if runtime.GOOS == "windows" { + // Junction points are removed with rmdir without deleting the target directory. + if err := exec.Command("cmd", "/c", "rmdir", path).Run(); err == nil { + return nil + } + } + return os.RemoveAll(path) +} + +func linkInclude(src, dst string) error { + absSrc, err := filepath.Abs(src) + if err != nil { + return err + } + absDst, err := filepath.Abs(dst) + if err != nil { + return err + } + + if runtime.GOOS != "windows" { + return os.Symlink(absSrc, absDst) + } + + if err := os.Symlink(absSrc, absDst); err == nil { + return nil + } + if err := exec.Command("cmd", "/c", "mklink", "/J", absDst, absSrc).Run(); err == nil { + return nil + } + return copyDir(absSrc, absDst) +} + +func copyDir(src, dst string) error { + if err := os.MkdirAll(dst, 0o755); err != nil { + return err + } + return filepath.WalkDir(src, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyFile(path, target) + }) +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + if err = os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} diff --git a/backend/scripts/setup-gofitz-cgo/main_test.go b/backend/scripts/setup-gofitz-cgo/main_test.go new file mode 100644 index 00000000..ba5154f0 --- /dev/null +++ b/backend/scripts/setup-gofitz-cgo/main_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCopyFile(t *testing.T) { + t.Parallel() + + srcDir := t.TempDir() + dstDir := t.TempDir() + + src := filepath.Join(srcDir, "header.h") + if err := os.WriteFile(src, []byte("test content"), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + + dst := filepath.Join(dstDir, "nested", "header.h") + if err := copyFile(src, dst); err != nil { + t.Fatalf("copyFile: %v", err) + } + + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("read destination: %v", err) + } + if string(got) != "test content" { + t.Fatalf("content = %q, want %q", string(got), "test content") + } +} + +func TestCopyDir_nestedFiles(t *testing.T) { + t.Parallel() + + srcDir := t.TempDir() + dstDir := t.TempDir() + + nested := filepath.Join(srcDir, "mupdf") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + if err := os.WriteFile(filepath.Join(srcDir, "root.txt"), []byte("root"), 0o644); err != nil { + t.Fatalf("write root: %v", err) + } + if err := os.WriteFile(filepath.Join(nested, "fitz.h"), []byte("header"), 0o644); err != nil { + t.Fatalf("write nested: %v", err) + } + + if err := copyDir(srcDir, dstDir); err != nil { + t.Fatalf("copyDir: %v", err) + } + + root, err := os.ReadFile(filepath.Join(dstDir, "root.txt")) + if err != nil { + t.Fatalf("read root copy: %v", err) + } + if string(root) != "root" { + t.Fatalf("root content = %q, want root", string(root)) + } + + nestedCopy, err := os.ReadFile(filepath.Join(dstDir, "mupdf", "fitz.h")) + if err != nil { + t.Fatalf("read nested copy: %v", err) + } + if string(nestedCopy) != "header" { + t.Fatalf("nested content = %q, want header", string(nestedCopy)) + } +} + +func TestRemoveExisting_directory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + target := filepath.Join(dir, "gofitzinclude") + if err := os.MkdirAll(filepath.Join(target, "mupdf"), 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } + if err := os.WriteFile(filepath.Join(target, "mupdf", "fitz.h"), []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + if err := removeExisting(target); err != nil { + t.Fatalf("removeExisting: %v", err) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("target still exists after removeExisting: %v", err) + } +} + +func TestRemoveExisting_symlink(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + src := filepath.Join(dir, "include") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatalf("mkdir src: %v", err) + } + + link := filepath.Join(dir, "gofitzinclude") + if err := os.Symlink(src, link); err != nil { + t.Skipf("symlinks not supported: %v", err) + } + + if err := removeExisting(link); err != nil { + t.Fatalf("removeExisting symlink: %v", err) + } + if _, err := os.Lstat(link); !os.IsNotExist(err) { + t.Fatalf("symlink still exists: %v", err) + } + if _, err := os.Stat(src); err != nil { + t.Fatalf("symlink target removed: %v", err) + } +} + +func TestRemoveExisting_missingPath(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + missing := filepath.Join(dir, "missing") + if err := removeExisting(missing); err != nil { + t.Fatalf("removeExisting missing: %v", err) + } +} diff --git a/backend/swagger/docs/docs.go b/backend/swagger/docs/docs.go index 750305a8..3ad6f417 100644 --- a/backend/swagger/docs/docs.go +++ b/backend/swagger/docs/docs.go @@ -7896,6 +7896,10 @@ const docTemplate = `{ "description": "filename for external, or descriptive name for embedded", "type": "string" }, + "srclang": { + "description": "unique HTML track language tag for Plyr (may differ from Language when duplicated)", + "type": "string" + }, "title": { "description": "title/description", "type": "string" diff --git a/backend/swagger/docs/swagger.json b/backend/swagger/docs/swagger.json index 75e6fc2d..bb746f79 100644 --- a/backend/swagger/docs/swagger.json +++ b/backend/swagger/docs/swagger.json @@ -7885,6 +7885,10 @@ "description": "filename for external, or descriptive name for embedded", "type": "string" }, + "srclang": { + "description": "unique HTML track language tag for Plyr (may differ from Language when duplicated)", + "type": "string" + }, "title": { "description": "title/description", "type": "string" diff --git a/backend/swagger/docs/swagger.yaml b/backend/swagger/docs/swagger.yaml index d5e7ec8d..ac784f74 100644 --- a/backend/swagger/docs/swagger.yaml +++ b/backend/swagger/docs/swagger.yaml @@ -2067,6 +2067,10 @@ definitions: name: description: filename for external, or descriptive name for embedded type: string + srclang: + description: unique HTML track language tag for Plyr (may differ from Language + when duplicated) + type: string title: description: title/description type: string diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c76e7191..7ff25c0a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1374,6 +1374,23 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1488,9 +1505,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", - "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", "cpu": [ "arm" ], @@ -1502,9 +1519,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", - "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", "cpu": [ "arm64" ], @@ -1516,9 +1533,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", - "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", "cpu": [ "arm64" ], @@ -1530,9 +1547,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", - "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", "cpu": [ "x64" ], @@ -1544,9 +1561,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", - "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", "cpu": [ "arm64" ], @@ -1558,9 +1575,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", - "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", "cpu": [ "x64" ], @@ -1572,9 +1589,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", - "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", "cpu": [ "arm" ], @@ -1586,9 +1603,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", - "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", "cpu": [ "arm" ], @@ -1600,9 +1617,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", - "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", "cpu": [ "arm64" ], @@ -1614,9 +1631,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", - "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", "cpu": [ "arm64" ], @@ -1628,9 +1645,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", - "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", "cpu": [ "loong64" ], @@ -1642,9 +1659,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", - "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", "cpu": [ "loong64" ], @@ -1656,9 +1673,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", - "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", "cpu": [ "ppc64" ], @@ -1670,9 +1687,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", - "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", "cpu": [ "ppc64" ], @@ -1684,9 +1701,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", - "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", "cpu": [ "riscv64" ], @@ -1698,9 +1715,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", - "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", "cpu": [ "riscv64" ], @@ -1712,9 +1729,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", - "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", "cpu": [ "s390x" ], @@ -1726,9 +1743,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", - "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", "cpu": [ "x64" ], @@ -1740,9 +1757,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", - "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", "cpu": [ "x64" ], @@ -1754,9 +1771,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", - "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", "cpu": [ "x64" ], @@ -1768,9 +1785,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", - "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", "cpu": [ "arm64" ], @@ -1782,9 +1799,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", - "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", "cpu": [ "arm64" ], @@ -1796,9 +1813,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", - "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", "cpu": [ "ia32" ], @@ -1810,9 +1827,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", - "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", "cpu": [ "x64" ], @@ -1824,9 +1841,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", - "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", "cpu": [ "x64" ], @@ -1932,17 +1949,17 @@ "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", - "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/type-utils": "8.65.0", - "@typescript-eslint/utils": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1955,22 +1972,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.65.0", + "@typescript-eslint/parser": "^8.67.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", - "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "engines": { @@ -1986,14 +2003,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", - "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.65.0", - "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "engines": { @@ -2008,14 +2025,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", - "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0" + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2026,9 +2043,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", - "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", "dev": true, "license": "MIT", "engines": { @@ -2043,15 +2060,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", - "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2068,9 +2085,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", - "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", "dev": true, "license": "MIT", "engines": { @@ -2082,16 +2099,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", - "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.65.0", - "@typescript-eslint/tsconfig-utils": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2110,16 +2127,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", - "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0" + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2134,13 +2151,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", - "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2331,39 +2348,39 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.40", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", - "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@vue/shared": "3.5.40", + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.40", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", - "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.40", - "@vue/shared": "3.5.40" + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.40", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", - "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@vue/compiler-core": "3.5.40", - "@vue/compiler-dom": "3.5.40", - "@vue/compiler-ssr": "3.5.40", - "@vue/shared": "3.5.40", + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", @@ -2371,13 +2388,13 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.40", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", - "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.40", - "@vue/shared": "3.5.40" + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" } }, "node_modules/@vue/compiler-vue2": { @@ -2423,57 +2440,57 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.40", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", - "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.40" + "@vue/shared": "3.5.41" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.40", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", - "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.40", - "@vue/shared": "3.5.40" + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.40", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", - "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.40", - "@vue/runtime-core": "3.5.40", - "@vue/shared": "3.5.40", + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.40", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", - "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.40", - "@vue/runtime-dom": "3.5.40", - "@vue/shared": "3.5.40" + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" } }, "node_modules/@vue/shared": { - "version": "3.5.40", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", - "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", "license": "MIT" }, "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz", + "integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2555,9 +2572,9 @@ "license": "MIT" }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "dev": true, "license": "MIT", "engines": { @@ -2789,11 +2806,14 @@ "license": "MIT" }, "node_modules/core-js": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", - "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", "hasInstallScript": true, "license": "MIT", + "engines": { + "node": "*" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -2942,16 +2962,16 @@ "license": "MIT" }, "node_modules/deepl-node": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.27.0.tgz", - "integrity": "sha512-OEiprFdxqr7aS0+gmv8vsZLBLoDarBppG1zkTWUlrAvPoYdwSWKFWjcZgr9XSV3FWlPy8g10WdJjWkxDn9P56w==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.28.0.tgz", + "integrity": "sha512-Kb4K1OVYuqFrHMHBSQq25HAhrcLjlFrkxYsWRfhbuPpEVfODOWiIdggZA+8JQ43egP/vLAReAj9h2mrR+15Ezw==", "dev": true, "license": "MIT", "dependencies": { "@types/node": ">=12.0", - "adm-zip": "^0.5.16", - "axios": "^1.7.4", - "form-data": "^3.0.4", + "adm-zip": "^0.6.0", + "axios": "^1.19.0", + "form-data": "^3.0.5", "loglevel": ">=1.6.2" }, "engines": { @@ -2991,9 +3011,9 @@ "license": "BSD-2-Clause" }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -3235,9 +3255,9 @@ } }, "node_modules/eslint": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", - "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -3888,9 +3908,9 @@ } }, "node_modules/globals": { - "version": "17.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", - "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", "dev": true, "license": "MIT", "engines": { @@ -3969,9 +3989,9 @@ } }, "node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", "license": "BSD-3-Clause", "engines": { "node": ">=12.0.0" @@ -4304,16 +4324,16 @@ } }, "node_modules/jsonc-eslint-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-3.1.0.tgz", - "integrity": "sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-3.3.0.tgz", + "integrity": "sha512-hYTGkHGNRZnXOFZ1urhINADoqDrGfpy53cjw+dxk84QE0pUDujQzeUeamNs6Mz44/TKD49z2x6/GVSu4ZrtA+Q==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^5.0.0", - "semver": "^7.3.5" + "verkit": "^0.3.2" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -4481,9 +4501,9 @@ } }, "node_modules/mammoth": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", - "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.1.tgz", + "integrity": "sha512-nCH9KKjWi3jQ+i8bUKs7k1yrXtSEGpWgF8IYkzsFMcbn+5S6l4bZEBbyx2hOQErFiXPuAs9RPa6qjXVxhyx/8g==", "license": "BSD-2-Clause", "dependencies": { "@xmldom/xmldom": "^0.8.6", @@ -4636,9 +4656,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -4983,9 +5003,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -5002,7 +5022,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5011,9 +5031,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", - "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", + "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", "dev": true, "license": "MIT", "dependencies": { @@ -5156,9 +5176,9 @@ } }, "node_modules/rollup": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", - "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "dev": true, "license": "MIT", "dependencies": { @@ -5172,31 +5192,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.3", - "@rollup/rollup-android-arm64": "4.62.3", - "@rollup/rollup-darwin-arm64": "4.62.3", - "@rollup/rollup-darwin-x64": "4.62.3", - "@rollup/rollup-freebsd-arm64": "4.62.3", - "@rollup/rollup-freebsd-x64": "4.62.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", - "@rollup/rollup-linux-arm-musleabihf": "4.62.3", - "@rollup/rollup-linux-arm64-gnu": "4.62.3", - "@rollup/rollup-linux-arm64-musl": "4.62.3", - "@rollup/rollup-linux-loong64-gnu": "4.62.3", - "@rollup/rollup-linux-loong64-musl": "4.62.3", - "@rollup/rollup-linux-ppc64-gnu": "4.62.3", - "@rollup/rollup-linux-ppc64-musl": "4.62.3", - "@rollup/rollup-linux-riscv64-gnu": "4.62.3", - "@rollup/rollup-linux-riscv64-musl": "4.62.3", - "@rollup/rollup-linux-s390x-gnu": "4.62.3", - "@rollup/rollup-linux-x64-gnu": "4.62.3", - "@rollup/rollup-linux-x64-musl": "4.62.3", - "@rollup/rollup-openbsd-x64": "4.62.3", - "@rollup/rollup-openharmony-arm64": "4.62.3", - "@rollup/rollup-win32-arm64-msvc": "4.62.3", - "@rollup/rollup-win32-ia32-msvc": "4.62.3", - "@rollup/rollup-win32-x64-gnu": "4.62.3", - "@rollup/rollup-win32-x64-msvc": "4.62.3", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" } }, @@ -5551,9 +5572,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -5693,16 +5714,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", - "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.65.0", - "@typescript-eslint/parser": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0" + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5790,6 +5811,20 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/verkit": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.3.2.tgz", + "integrity": "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -6054,16 +6089,16 @@ "license": "MIT" }, "node_modules/vue": { - "version": "3.5.40", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", - "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.40", - "@vue/compiler-sfc": "3.5.40", - "@vue/runtime-dom": "3.5.40", - "@vue/server-renderer": "3.5.40", - "@vue/shared": "3.5.40" + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" }, "peerDependencies": { "typescript": "*" @@ -6388,9 +6423,9 @@ } }, "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/src/api/resources.js b/frontend/src/api/resources.js index 6a755a7c..62b3bed3 100644 --- a/frontend/src/api/resources.js +++ b/frontend/src/api/resources.js @@ -6,6 +6,7 @@ import { notifyDownloadComplete, notifyDownloadError, } from '@/utils/appNotifications' +import { renew } from '@/utils/auth' import { getApiPath, getPublicApiPath, getParentDir } from '@/utils/url.js' import { adjustedData, fetchURL } from './utils' import { rememberViewToken } from './viewToken' @@ -142,29 +143,34 @@ export async function bulkDelete(items) { } try { const apiPath = getApiPath('resources/bulk') - const response = await fetchURL(apiPath, { + const response = await fetch(apiPath, { method: 'DELETE', headers: { 'Content-Type': 'application/json', + 'sessionId': state.sessionId, }, + credentials: 'same-origin', body: JSON.stringify(items), }) const data = await response.json() // 200 = all succeeded, 207 = partial success (some succeeded, some failed) - // Both are valid responses that should be returned, not thrown as errors if (response.status === 200 || response.status === 207) { items.forEach((item) => { invalidateDirMetadataCache({ source: item.source, path: getParentDir(item.path) }) }) return data } - // For other error status codes, throw an error const error = new Error(data.message || response.statusText) error.status = response.status + if (data.failed) { + error.failed = data.failed + } + if (data.succeeded) { + error.succeeded = data.succeeded + } throw error } catch (err) { - // Only show notification and re-throw if it's a real error (not 200/207) - if (err.status && err.status !== 200 && err.status !== 207) { + if (!err.status || (err.status !== 500 && err.status !== 200 && err.status !== 207)) { notify.showError(err.message || 'Error performing bulk delete') } throw err @@ -695,33 +701,79 @@ export function post( ...(isDir && { isDir: 'true' }) }); - const request = new XMLHttpRequest(); - request.open("POST", apiPath, true); + const opState = { xhr: null, aborted: false }; - Object.entries(headers).forEach(([header, value]) => { - request.setRequestHeader(header, value); - }); + const rejectIfAborted = (reject) => { + if (!opState.aborted) { + return false; + } + reject(new Error("Upload aborted")); + return true; + }; - if (typeof onupload === "function") { - request.upload.onprogress = (event) => { - if (event.lengthComputable) { - const percentComplete = Math.round( - (event.loaded / event.total) * 100 - ); - onupload(percentComplete); // Pass the percentage to the callback + const startRequest = (isRetry) => + new Promise((resolve, reject) => { + if (rejectIfAborted(reject)) { + return; } - }; - } + const request = new XMLHttpRequest(); + opState.xhr = request; + request.open("POST", apiPath, true); - const promise = new Promise((resolve, reject) => { - request.onload = () => { - if (request.status >= 200 && request.status < 300) { - resolve(request.responseText); - } else if (request.status === 409) { - const error = new Error("conflict"); - error.response = { status: request.status, responseText: request.responseText }; - reject(error); - } else { + Object.entries(headers).forEach(([header, value]) => { + request.setRequestHeader(header, value); + }); + + if (typeof onupload === "function") { + request.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percentComplete = Math.round( + (event.loaded / event.total) * 100 + ); + onupload(percentComplete); + } + }; + } + + request.onload = () => { + if (request.status >= 200 && request.status < 300) { + resolve(request.responseText); + return; + } + if (request.status === 409) { + const error = new Error("conflict"); + error.response = { status: request.status, responseText: request.responseText }; + reject(error); + return; + } + // Session expired mid-upload: renew once and retry the same body. + if (request.status === 401 && !isRetry && !getters.isShare()) { + void renew() + .then(() => { + if (rejectIfAborted(reject)) { + return; + } + return startRequest(true).then(resolve, reject); + }) + .catch(() => { + if (rejectIfAborted(reject)) { + return; + } + let errorMessage = "Upload failed"; + try { + const errorData = JSON.parse(request.responseText); + errorMessage = errorData.message || errorMessage; + } catch (_e) { + errorMessage = request.responseText || errorMessage; + } + const error = new Error(errorMessage); + error.status = request.status; + error.response = { status: request.status, responseText: request.responseText }; + notify.showError(errorMessage); + reject(error); + }); + return; + } let errorMessage = "Upload failed"; try { const errorData = JSON.parse(request.responseText); @@ -734,29 +786,45 @@ export function post( error.response = { status: request.status, responseText: request.responseText }; notify.showError(errorMessage); reject(error); + }; + + request.onerror = () => reject(new Error("Network error")); + request.onabort = () => reject(new Error("Upload aborted")); + + if ( + content instanceof Blob && + !["http:", "https:"].includes(window.location.protocol) + ) { + new Response(content).arrayBuffer() + .then((buffer) => { + if (rejectIfAborted(reject)) { + return; + } + request.send(buffer); + }) + .catch((err) => reject(err)); + } else { + request.send(content); } - }; + }); - request.onerror = () => reject(new Error("Network error")); - request.onabort = () => reject(new Error("Upload aborted")); - - if ( - content instanceof Blob && - !["http:", "https:"].includes(window.location.protocol) - ) { - new Response(content).arrayBuffer() - .then(buffer => request.send(buffer)) - .catch(err => reject(err)); - } else { - request.send(content); - } + const uploadPromise = startRequest(false).then((result) => { + invalidateDirMetadataCache({ source, path: getParentDir(path) }); + return result; }); - - promise.xhr = request; - promise.then(() => { - invalidateDirMetadataCache({ source, path: getParentDir(path) }) - }).catch(() => { /* upload failed, so do nothing*/ }); - return promise; + // Preserve .xhr for pause/abort; re-attach after .then() so callers still see it. + void Object.defineProperty(uploadPromise, "xhr", { + get: () => opState.xhr, + enumerable: true, + }); + // Operation-level abort: stops a pending 401 renew+retry as well as the active XHR. + uploadPromise.abort = () => { + opState.aborted = true; + if (opState.xhr && opState.xhr.readyState !== XMLHttpRequest.DONE) { + opState.xhr.abort(); + } + }; + return uploadPromise; } catch (err) { notify.showError(err.message || "Error posting resource"); // We are returning a promise, so we should return a rejected promise on error. diff --git a/frontend/src/api/utils.js b/frontend/src/api/utils.js index f147bec2..513d2a04 100644 --- a/frontend/src/api/utils.js +++ b/frontend/src/api/utils.js @@ -1,10 +1,5 @@ import i18n from "@/i18n"; -import { getters, state } from "@/store"; -import { renew } from "@/utils/auth"; - -function isPublicApiUrl(url) { - return typeof url === "string" && url.includes("public/api/"); -} +import { state } from "@/store"; const defaultRequestTimeoutMs = 5000; @@ -20,7 +15,7 @@ export function requestTimeoutSignal(ms = defaultRequestTimeoutMs) { return controller.signal; } -export async function fetchURL(url, opts, auth = true) { +export async function fetchURL(url, opts, _auth = true) { opts = opts || {}; opts.headers = opts.headers || {}; @@ -48,16 +43,7 @@ export async function fetchURL(url, opts, auth = true) { throw error; } - if ( - auth && - res.headers.get("X-Renew-Token") === "true" && - !getters.isShare() && - !isPublicApiUrl(url) - ) { - // Cookie is automatically sent, no need to pass JWT from state. - // Skip on public share routes: renew hits /api/auth/renew, which may sit behind proxy basic auth. - await renew(); - } + // Session JWT renew is handled by utils/auth session keep-alive (not per-request headers). if (res.status < 200 || res.status > 299) { const error = new Error(await res.text()); diff --git a/frontend/src/api/viewToken.js b/frontend/src/api/viewToken.js index d912a98a..0c61b161 100644 --- a/frontend/src/api/viewToken.js +++ b/frontend/src/api/viewToken.js @@ -1,7 +1,13 @@ import { getters, state } from "@/store"; import { getApiPath, getPublicApiPath } from "@/utils/url.js"; +import { + VIEW_REFRESH_BEFORE_MS, + msUntilRefresh, + shouldRefreshBeforeExpiry, +} from "@/utils/auth"; -const REFRESH_BEFORE_MS = 10 * 60 * 1000; +/** @deprecated use VIEW_REFRESH_BEFORE_MS — kept as alias for clarity in this module */ +const REFRESH_BEFORE_MS = VIEW_REFRESH_BEFORE_MS; let activeScope = ""; let refreshTimer = null; @@ -96,7 +102,8 @@ function scheduleViewGrantRefresh(source, scope) { if (!cached?.expiresAt) { return; } - const delay = Math.max(0, cached.expiresAt * 1000 - Date.now() - REFRESH_BEFORE_MS); + // Same pattern as session keep-alive: refresh before expiry, not on request headers. + const delay = msUntilRefresh(cached.expiresAt, REFRESH_BEFORE_MS); if (refreshTimer) { clearTimeout(refreshTimer); } @@ -173,10 +180,9 @@ export async function refreshViewToken(source, existingToken, requestScope = nul export async function ensureViewToken(source) { const scope = viewGrantScope(source); const cached = readCacheForScope(scope); - const now = Date.now(); if ( cached?.viewToken && - cached.expiresAt * 1000 - now > REFRESH_BEFORE_MS + !shouldRefreshBeforeExpiry(cached.expiresAt, REFRESH_BEFORE_MS) ) { return cached.viewToken; } diff --git a/frontend/src/components/ButtonGroup.vue b/frontend/src/components/ButtonGroup.vue index a587974d..def1cb66 100644 --- a/frontend/src/components/ButtonGroup.vue +++ b/frontend/src/components/ButtonGroup.vue @@ -9,7 +9,7 @@ type="button" :key="index" class="clickable" - :class="{ active: activeButton === index }" + :class="{ active: !actionMode && activeButton === index }" :disabled="isDisabled" @click="setActiveButton(index, btn.value)" > @@ -38,6 +38,11 @@ export default { type: String, default: "", }, + /** When true, clicks always emit button-clicked (no toggle/active selection). */ + actionMode: { + type: Boolean, + default: false, + }, }, data() { return { @@ -53,6 +58,10 @@ export default { if (this.isDisabled) { return; } + if (this.actionMode) { + this.$emit("button-clicked", value); + return; + } if (value === "type:folder" && this.activeButton !== index) { this.$emit("disableAll"); } @@ -83,6 +92,10 @@ export default { initialActive: { immediate: true, handler(newVal) { + if (this.actionMode) { + this.activeButton = null; + return; + } // Find the button whose value matches initialActive const initialIndex = this.buttons.findIndex((btn) => btn.value === newVal); this.activeButton = initialIndex !== -1 ? initialIndex : null; // Set to matching button index or null @@ -98,6 +111,8 @@ export default { margin: 1em; display: flex; flex-wrap: wrap; + width: 100%; + max-width: 100%; border: var(--borderWidth) solid var(--surfaceSecondary); box-shadow: var(--surfaceElevationShadow); overflow: hidden; @@ -106,7 +121,8 @@ export default { button { cursor: pointer; - flex: 1; + flex: 1 1 auto; + min-width: 0; height: 3em; padding: 8px 16px; border: none; diff --git a/frontend/src/components/ContextMenu.vue b/frontend/src/components/ContextMenu.vue index c9917dde..c61242bf 100644 --- a/frontend/src/components/ContextMenu.vue +++ b/frontend/src/components/ContextMenu.vue @@ -286,7 +286,31 @@ export default { return this.showLimitedOptions && this.selectedCount === 1; }, permissions() { - return { ...getters.globalPermissions(), ...getters.sourcePermissions() }; + const global = getters.globalPermissions(); + const items = this.providedItems; + + if (items.length > 0) { + const merged = { + view: true, + download: true, + modify: true, + create: false, + delete: true, + }; + for (const item of items) { + const source = item.source || state.sources?.current || ""; + const perms = getters.sourcePermissions(source); + merged.view = merged.view && perms.view; + merged.download = merged.download && perms.download; + merged.modify = merged.modify && perms.modify; + merged.create = merged.create || perms.create; + merged.delete = merged.delete && perms.delete; + } + return { ...global, ...merged }; + } + + const source = state.req?.source || state.sources?.current || ""; + return { ...global, ...getters.sourcePermissions(source) }; }, /** * Whether the (+) panel can be opened (create file ops and/or admin-only rows like access rules). @@ -382,7 +406,11 @@ export default { return !this.showCreate && this.selectedCount === 1 && !!state.user?.showCopyPath; }, showOpenParentFolder() { - return !this.showCreate && this.selectedCount === 1 && (this.isSearchActive || this.showLimitedOptions); + return ( + !this.showCreate && + this.selectedCount === 1 && + (this.isSearchActive || this.showLimitedOptions || getters.currentTool()?.component === "AdvancedSearch") + ); }, showMove() { if (this.showLimitedOptions) return false; diff --git a/frontend/src/components/Search.vue b/frontend/src/components/Search.vue index 1e7d9e2f..d600a6c6 100644 --- a/frontend/src/components/Search.vue +++ b/frontend/src/components/Search.vue @@ -156,6 +156,7 @@ import { getters, mutations, state } from "@/store"; import { url } from "@/utils/"; import { globalVars } from "@/utils/constants"; import { getHumanReadableFilesize } from "@/utils/filesizes"; +import { isTypeAheadSessionActive } from "@/utils/listingTypeAhead.js"; import { utcStartOfDaySecondsFromDateInput } from "@/utils/moment"; const boxes = { @@ -266,6 +267,14 @@ export default { // Add keyboard event listener for "/" to activate search this.handleKeydown = (event) => { + if ( + (event.key === '/' || event.key === ' ') && + isTypeAheadSessionActive() && + getters.currentView() === 'listingView' + ) { + event.preventDefault(); + return; + } if ((event.key === '/' || event.key === ' ') && !state.isSearchActive && !getters.currentPrompt()) { event.preventDefault(); this.open(); diff --git a/frontend/src/components/files/ExtendedImage.vue b/frontend/src/components/files/ExtendedImage.vue index e87fa26a..8541ba67 100644 --- a/frontend/src/components/files/ExtendedImage.vue +++ b/frontend/src/components/files/ExtendedImage.vue @@ -79,8 +79,10 @@ export default { isTiff: false, // Full-frame edge gestures (touch + mouse), zoom scale === 1 only edgeKind: null, // null | 'horizontal' | 'vertical-dismiss' + edgeGestureActive: false, // true only when this touch started at scale 1 edgeStartX: 0, edgeStartY: 0, + edgeStartClientX: 0, edgeDx: 0, edgeDy: 0, dragOffsetX: 0, @@ -98,6 +100,13 @@ export default { edgeRubberMax: 100, pinchActive: false, viewTokenRetried: false, + zoomTapStartX: 0, + zoomTapStartY: 0, + zoomTapMoved: false, + zoomTapToggleTimer: null, + zoomMouseMoved: false, + zoomMouseStartX: 0, + zoomMouseStartY: 0, }; }, computed: { @@ -200,6 +209,7 @@ export default { clearTimeout(this.disabledTimer); this.disabledTimer = null; } + this.cancelZoomTapNavToggle(); this.teardownEdgeMouseListeners(); mutations.setNavigationGestureHint({}); window.removeEventListener("resize", this.onResize); @@ -226,6 +236,28 @@ export default { } mutations.setNavigationGestureHint({ kind, commitReady, flashClose }); }, + cancelZoomTapNavToggle() { + if (this.zoomTapToggleTimer) { + clearTimeout(this.zoomTapToggleTimer); + this.zoomTapToggleTimer = null; + } + }, + scheduleZoomTapNavToggle() { + this.cancelZoomTapNavToggle(); + this.zoomTapToggleTimer = setTimeout(() => { + this.zoomTapToggleTimer = null; + if (this.navigationGestureAllowed) { + mutations.toggleNavigationChrome(); + } + }, 300); + }, + tryToggleNavOnZoomedTap(startX, startY, endX, endY) { + const ax = Math.abs(endX - startX); + const ay = Math.abs(endY - startY); + if (ax < this.edgeHintPx && ay < this.edgeHintPx) { + this.scheduleZoomTapNavToggle(); + } + }, loadFullImage() { if (!this.src) return; mutations.setLoading("preview-img", true); @@ -473,6 +505,7 @@ export default { }, resetEdgeGestureImmediate() { this.edgeKind = null; + this.edgeGestureActive = false; this.gestureDecided = false; this.edgeDx = 0; this.edgeDy = 0; @@ -495,6 +528,9 @@ export default { const ax = Math.abs(this.edgeDx); const ay = Math.abs(this.edgeDy); if (ax < this.edgeHintPx && ay < this.edgeHintPx) { + if (this.navigationGestureAllowed) { + mutations.toggleNavigationChrome(); + } this.snapBackEdgeGesture(); return; } @@ -580,6 +616,7 @@ export default { this.edgeMouseActive = true; this.edgeStartX = event.clientX; this.edgeStartY = event.clientY; + this.edgeStartClientX = event.clientX; this.edgeDx = 0; this.edgeDy = 0; this.edgeKind = null; @@ -592,11 +629,18 @@ export default { this.lastX = null; this.lastY = null; this.inDrag = true; + this.zoomMouseMoved = false; + this.cancelZoomTapNavToggle(); + this.zoomMouseStartX = event.clientX; + this.zoomMouseStartY = event.clientY; event.preventDefault(); }, mouseMove(event) { if (event.button !== 0) return; if (this.scale > 1 && this.inDrag) { + if (event.movementX !== 0 || event.movementY !== 0) { + this.zoomMouseMoved = true; + } this.doMove(event.movementX, event.movementY); event.preventDefault(); } @@ -604,6 +648,14 @@ export default { mouseUp(event) { if (event.button !== 0) return; if (this.scale > 1) { + if (!this.zoomMouseMoved) { + this.tryToggleNavOnZoomedTap( + this.zoomMouseStartX, + this.zoomMouseStartY, + event.clientX, + event.clientY, + ); + } this.inDrag = false; event.preventDefault(); } @@ -621,22 +673,36 @@ export default { } if (event.targetTouches.length === 1 && this.scale === 1) { const touch = event.targetTouches[0]; + this.edgeGestureActive = true; this.edgeStartX = touch.pageX; this.edgeStartY = touch.pageY; + this.edgeStartClientX = touch.clientX; this.edgeDx = 0; this.edgeDy = 0; this.edgeKind = null; this.gestureDecided = false; + } else if (event.targetTouches.length === 1 && this.scale > 1) { + this.cancelZoomTapNavToggle(); + const touch = event.targetTouches[0]; + this.zoomTapStartX = touch.pageX; + this.zoomTapStartY = touch.pageY; + this.zoomTapMoved = false; } else { this.resetEdgeGestureImmediate(); this.applyImgTransform(); } - if (event.targetTouches.length === 2) { + // Sequential single-finger taps within 300ms → double-tap zoom + // (@dblclick is unreliable on mobile because touchend preventDefault blocks click synthesis) + if (event.targetTouches.length === 1) { setTimeout(() => { this.touches = 0; }, 300); this.touches++; if (this.touches > 1) { + this.cancelZoomTapNavToggle(); + // zoomAuto may set scale back to 1 before touchend; do not treat that + // touchend as an edge swipe (stale edgeStart would navigate). + this.edgeGestureActive = false; this.zoomAuto(event); event.preventDefault(); } @@ -701,15 +767,21 @@ export default { } if (event.targetTouches.length === 1 && this.scale > 1) { if (this.moveDisabled) return; + const touch = event.targetTouches[0]; + const ax = Math.abs(touch.pageX - this.zoomTapStartX); + const ay = Math.abs(touch.pageY - this.zoomTapStartY); + if (ax > 10 || ay > 10) { + this.zoomTapMoved = true; + } if (this.lastX === null) { - this.lastX = event.targetTouches[0].pageX; - this.lastY = event.targetTouches[0].pageY; + this.lastX = touch.pageX; + this.lastY = touch.pageY; return; } - const x = event.targetTouches[0].pageX - this.lastX; - const y = event.targetTouches[0].pageY - this.lastY; - this.lastX = event.targetTouches[0].pageX; - this.lastY = event.targetTouches[0].pageY; + const x = touch.pageX - this.lastX; + const y = touch.pageY - this.lastY; + this.lastX = touch.pageX; + this.lastY = touch.pageY; this.doMove(x, y); } }, @@ -744,15 +816,28 @@ export default { event.preventDefault(); return; } - if (this.scale === 1 && event.changedTouches.length > 0) { + // Only finish edge gestures that started at scale 1. Double-tap zoom-out + // sets scale to 1 on touchstart; without this guard touchend would treat + // stale edgeStart coords as a swipe and navigate. + if (this.scale === 1 && this.edgeGestureActive && event.changedTouches.length > 0) { const t = event.changedTouches[0]; this.edgeDx = t.pageX - this.edgeStartX; this.edgeDy = t.pageY - this.edgeStartY; this.finishEdgeGesture(); event.preventDefault(); - } else if (this.scale > 1) { + } else if (this.scale > 1 && event.changedTouches.length > 0) { + if (!this.zoomTapMoved) { + const t = event.changedTouches[0]; + this.tryToggleNavOnZoomedTap( + this.zoomTapStartX, + this.zoomTapStartY, + t.pageX, + t.pageY, + ); + } event.preventDefault(); } + this.edgeGestureActive = false; }, }, watch: { diff --git a/frontend/src/components/files/Icon.vue b/frontend/src/components/files/Icon.vue index 28ce6ccd..154facb2 100644 --- a/frontend/src/components/files/Icon.vue +++ b/frontend/src/components/files/Icon.vue @@ -512,6 +512,28 @@ export default { opacity: 0.7; } +.overlay-icon--bottom { + top: auto; + left: auto; + bottom: 0; + right: 0; + width: auto !important; + height: auto !important; + pointer-events: auto; + cursor: pointer; + border: none; + padding: 0; + margin: 0; + background: none; + line-height: 1; +} + +.overlay-icon--bottom.clickable:hover { + transform: none; + box-shadow: none !important; + opacity: 1; +} + .file-icons [aria-label^="."] { opacity: 0.33; } @@ -648,6 +670,10 @@ export default { position: relative; } +.gallery .image-preview { + border-radius: 0; +} + /* Universal icon styling - uses --icon-font-size variable */ .image-preview i { padding: 0.1em; diff --git a/frontend/src/components/files/ListingItem.vue b/frontend/src/components/files/ListingItem.vue index 4127a302..1509d82f 100644 --- a/frontend/src/components/files/ListingItem.vue +++ b/frontend/src/components/files/ListingItem.vue @@ -46,6 +46,13 @@ :isShared="isShared" :viewToken="viewToken" /> +
@@ -68,7 +75,7 @@ +
@@ -241,9 +255,9 @@ export default { }, quickDownloadEnabled() { if (getters.isShare()) { - return state.shareInfo?.quickDownload && !this.galleryView; + return state.shareInfo?.quickDownload; } - return state.user?.quickDownload && !this.galleryView; + return state.user?.quickDownload; }, quickDownloadPlaceholder() { if (getters.isShare()) { @@ -762,6 +776,15 @@ export default { mutations.resetSelected(); mutations.addSelected(this.index); mutations.setLastSelectedIndex(this.index); + return; + } + + // Single-select: clicking the only selected item again should deselect it. + // ListingView's clickClear only runs when the click target is a child element, + // but list/compact rows are full-width tags so clicks often hit the anchor directly. + mutations.resetSelected(); + if (event.currentTarget instanceof HTMLElement) { + event.currentTarget.blur(); } return; } diff --git a/frontend/src/components/files/nextPrevious.vue b/frontend/src/components/files/nextPrevious.vue index d86bdd4c..fd65b0ac 100644 --- a/frontend/src/components/files/nextPrevious.vue +++ b/frontend/src/components/files/nextPrevious.vue @@ -172,8 +172,10 @@ export default { ); }, autoShowNavForMediaPreview() { - const pt = getters.previewType(); - return pt === 'image' || pt === 'video'; + return getters.previewType() === 'video'; + }, + isImagePreview() { + return getters.previewType() === 'image'; }, /** Close mirrors swipe-down dismiss only (not with prev/next on load or edge hover). */ showCloseNavChrome() { @@ -483,24 +485,32 @@ export default { }); }, showInitialNavigation() { - // Show navigation initially for 3 seconds when navigation is set up - if (this.enabled && (this.hasPrevious || this.hasNext || this.autoShowNavForMediaPreview)) { - mutations.setNavigationShow(true); - - mutations.clearNavigationTimeout(); - if (this.navigationTimeout) { - clearTimeout(this.navigationTimeout); - this.navigationTimeout = null; - } - - this.navigationTimeout = setTimeout(() => { - if (!this.hoverNav) { - mutations.setNavigationShow(false); - } - this.navigationTimeout = null; - }, 3000); - mutations.setNavigationTimeout(this.navigationTimeout); + if (!this.enabled) { + return; } + if (!(this.hasPrevious || this.hasNext || this.autoShowNavForMediaPreview || this.isImagePreview)) { + return; + } + + mutations.clearNavigationTimeout(); + if (this.navigationTimeout) { + clearTimeout(this.navigationTimeout); + this.navigationTimeout = null; + } + + if (this.isImagePreview) { + mutations.showNavigationChromePersistent(); + return; + } + + mutations.setNavigationShow(true); + this.navigationTimeout = setTimeout(() => { + if (!this.hoverNav) { + mutations.setNavigationShow(false); + } + this.navigationTimeout = null; + }, 3000); + mutations.setNavigationTimeout(this.navigationTimeout); }, async handleClosePreviewClick() { if (!(await this.checkForUnsavedChanges())) { @@ -1009,7 +1019,7 @@ export default { transform: translateY(-50%); width: 50px; height: 50px; - border: none; + border: var(--borderWidth) solid var(--divider); border-radius: 50%; background: var(--background); color: var(--textPrimary); diff --git a/frontend/src/components/prompts/Delete.vue b/frontend/src/components/prompts/Delete.vue index 916a9ba5..f5f6e3ea 100644 --- a/frontend/src/components/prompts/Delete.vue +++ b/frontend/src/components/prompts/Delete.vue @@ -49,6 +49,7 @@ type="button" @click="closeTopPrompt" class="button button--flat button--grey" + :disabled="deleting" :aria-label="$t('general.cancel')" :title="$t('general.cancel')"> {{ $t("general.cancel") }} @@ -57,6 +58,7 @@ type="button" @click="submit" class="button button--flat button--red" + :disabled="deleting" aria-label="Confirm-Delete" :title="$t('general.delete')"> {{ $t("general.delete") }} @@ -179,14 +181,30 @@ export default { return parts[parts.length - 1] || path; }, getItemError(item) { - // Find matching failed item by comparing source and path for (const failedItem of this.failedItems.values()) { if (failedItem.source === item.source && failedItem.path === item.path) { - return failedItem.message || 'Unknown error'; + return failedItem.message || "Unknown error"; } } return null; }, + parseBulkDeleteFailures(error) { + if (error?.failed?.length) { + return error.failed; + } + if (!error?.message) { + return null; + } + try { + const parsed = JSON.parse(error.message); + if (Array.isArray(parsed?.failed) && parsed.failed.length > 0) { + return parsed.failed; + } + } catch (_e) { + // Not JSON — fall through + } + return null; + }, getPreviewUrl(source, path, modified, type) { if (!source || !path) return null; @@ -221,9 +239,9 @@ export default { try { // Extract source and path from items (ignore previewUrl) - const itemsForDelete = this.itemsToDelete.map(item => ({ + const itemsForDelete = this.itemsToDelete.map((item) => ({ source: item.source, - path: item.path + path: item.path, })); if (itemsForDelete.length === 0) { @@ -237,15 +255,12 @@ export default { ? await resourcesApi.bulkDeletePublic(itemsForDelete) : await resourcesApi.bulkDelete(itemsForDelete); - // Store failed items directly from response - if (response.failed && response.failed.length > 0) { - this.failedItems = response.failed; - } else { - this.failedItems = []; - } + const failed = Array.isArray(response?.failed) ? response.failed : []; + const succeeded = Array.isArray(response?.succeeded) ? response.succeeded : []; + this.failedItems = failed; - const succeededCount = response.succeeded ? response.succeeded.length : 0; - const failedCount = response.failed ? response.failed.length : 0; + const succeededCount = succeeded.length; + const failedCount = failed.length; if (failedCount === 0) { // All succeeded - close prompt and handle navigation @@ -254,8 +269,8 @@ export default { if (this.items && this.items.length > 0) { eventBus.emit("itemsDeleted", { - succeeded: response.succeeded || [], - failed: [] + succeeded, + failed, }); } mutations.closeTopPrompt(); @@ -304,13 +319,23 @@ export default { // Emit event with partial results if items were passed as props if (this.items && this.items.length > 0) { eventBus.emit("itemsDeleted", { - succeeded: response.succeeded || [], - failed: response.failed || [] + succeeded, + failed, }); } + if (failedCount > 0) { + notify.showError( + failed[0]?.message || this.$t("prompts.operationFailed") + ); + } } else { // All failed buttons.done("delete"); + if (failedCount > 0) { + notify.showError( + failed[0]?.message || this.$t("prompts.operationFailed") + ); + } } this.deleting = false; @@ -318,12 +343,20 @@ export default { buttons.done("delete"); this.deleting = false; console.error(e); - // On network/API errors, show error for all items - this.failedItems = this.itemsToDelete.map(item => ({ - source: item.source, - path: item.path, - message: e.message || 'Delete failed' - })); + const parsedFailures = this.parseBulkDeleteFailures(e); + if (parsedFailures) { + this.failedItems = parsedFailures; + notify.showError( + parsedFailures[0]?.message || this.$t("prompts.operationFailed") + ); + } else { + this.failedItems = this.itemsToDelete.map((item) => ({ + source: item.source, + path: item.path, + message: e.message || "Delete failed", + })); + notify.showError(e.message || this.$t("prompts.operationFailed")); + } } }, }, diff --git a/frontend/src/components/prompts/MoveCopy.vue b/frontend/src/components/prompts/MoveCopy.vue index 1cdd7aaf..099d9215 100644 --- a/frontend/src/components/prompts/MoveCopy.vue +++ b/frontend/src/components/prompts/MoveCopy.vue @@ -30,6 +30,7 @@ type="button" v-if="canCreateFolder && showNewDirInput" class="button button--flat" + :disabled="isLoading" @click="cancelNewDir" :aria-label="$t('general.cancel')" :title="$t('general.cancel')" @@ -40,6 +41,7 @@ type="button" v-if="canCreateFolder && !showNewDirInput" class="button button--flat" + :disabled="isLoading" @click="createNewDir" :aria-label="$t('files.newFolder')" :title="$t('files.newFolder')" @@ -50,7 +52,8 @@ v-model.trim="newDirName" :placeholder="$t('files.newFolderMessage')" @keydown.enter="handleEnter" /> @@ -274,6 +277,9 @@ export default { }, performOperation: async function (event) { event.preventDefault(); + if (this.isLoading) { + return; + } this.isLoading = true; // Show loading spinner try { // Ensure destination source is set diff --git a/frontend/src/components/prompts/Upload.vue b/frontend/src/components/prompts/Upload.vue index 1788fa7b..cb59a61e 100644 --- a/frontend/src/components/prompts/Upload.vue +++ b/frontend/src/components/prompts/Upload.vue @@ -40,39 +40,12 @@
cloud_upload

{{ $t("prompts.dragAndDrop") }}

-
- - - - -
+
@@ -219,6 +192,7 @@ import { notify } from "@/notify"; import ProgressBar from "@/components/ProgressBar.vue"; import SettingsItem from "@/components/settings/SettingsItem.vue"; import ToggleSwitch from "@/components/settings/ToggleSwitch.vue"; +import ButtonGroup from "@/components/ButtonGroup.vue"; import i18n from "@/i18n"; export default { @@ -227,6 +201,7 @@ export default { ProgressBar, SettingsItem, ToggleSwitch, + ButtonGroup, }, props: { initialItems: { @@ -517,6 +492,32 @@ export default { if (videoInput.value) videoInput.value.click(); }; + const uploadActionButtons = computed(() => [ + { label: i18n.global.t("general.file"), value: "file" }, + { label: i18n.global.t("general.folder"), value: "folder" }, + { label: i18n.global.t("general.camera"), value: "camera" }, + { label: i18n.global.t("general.video"), value: "video" }, + ]); + + const onUploadAction = (value) => { + switch (value) { + case "file": + triggerFilePicker(); + break; + case "folder": + triggerFolderPicker(); + break; + case "camera": + triggerCameraPicker(); + break; + case "video": + triggerVideoPicker(); + break; + default: + break; + } + }; + const onFilePicked = (event) => { const pickedFiles = event.target.files; if (pickedFiles.length > 0) { @@ -667,6 +668,8 @@ export default { triggerFolderPicker, triggerCameraPicker, triggerVideoPicker, + uploadActionButtons, + onUploadAction, onFilePicked, onFolderPicked, fileInput, @@ -706,15 +709,6 @@ export default {