forked from upstream/filebrowser
Beta/v2.0.1 (#2800)
Co-authored-by: Mattijs Bliek <mattijs@mattijsbliek.com> Co-authored-by: GammelSami <39372285+GammelSami@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
100 files changed
+5906
-1135
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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}' \
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+1
-43
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
video: true
|
||||
audio: true
|
||||
motionVideoPreview: true
|
||||
office: true
|
||||
popup: true
|
||||
folder: true
|
||||
models: true
|
||||
+12
-14
@@ -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
|
||||
|
||||
+67
-118
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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[:]...)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
gofitzinclude
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,272 @@
|
||||
//go:build mupdf
|
||||
|
||||
package preview
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -I${SRCDIR}/gofitzinclude
|
||||
|
||||
#include <mupdf/fitz.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "/" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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=<login>")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+304
-269
File diff suppressed because it is too large.
Load diff
+116
-48
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -46,6 +46,13 @@
|
||||
:isShared="isShared"
|
||||
:viewToken="viewToken"
|
||||
/>
|
||||
<button
|
||||
v-if="quickDownloadEnabled && galleryView"
|
||||
type="button"
|
||||
class="material-symbols overlay-icon overlay-icon--bottom clickable"
|
||||
aria-label="Download"
|
||||
@click.stop.prevent="downloadFile"
|
||||
>file_download</button> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</div>
|
||||
|
||||
<div class="text">
|
||||
@@ -68,7 +75,7 @@
|
||||
|
||||
<Icon
|
||||
@click.stop="downloadFile"
|
||||
v-if="quickDownloadEnabled"
|
||||
v-if="quickDownloadEnabled && !galleryView"
|
||||
:filename="name"
|
||||
:hasPreview="hasPreview"
|
||||
mimetype="file_download"
|
||||
@@ -120,6 +127,13 @@
|
||||
:isShared="isShared"
|
||||
:viewToken="viewToken"
|
||||
/>
|
||||
<button
|
||||
v-if="quickDownloadEnabled && galleryView"
|
||||
type="button"
|
||||
class="material-symbols overlay-icon overlay-icon--bottom clickable"
|
||||
aria-label="Download"
|
||||
@click.stop.prevent="downloadFile"
|
||||
>file_download</button> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</div>
|
||||
|
||||
<div class="text">
|
||||
@@ -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 <a> tags so clicks often hit the anchor directly.
|
||||
mutations.resetSelected();
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" />
|
||||
<button
|
||||
type="button"
|
||||
v-else :disabled="destContainsSrc"
|
||||
v-else
|
||||
:disabled="destContainsSrc || isLoading"
|
||||
class="button button--flat"
|
||||
@click="performOperation"
|
||||
:aria-label="operation === 'move' ? $t('general.move') : $t('general.copy')"
|
||||
@@ -63,7 +66,7 @@
|
||||
v-if="showNewDirInput"
|
||||
class="button button--flat"
|
||||
@click="createDirectory"
|
||||
:disabled="!newDirName || !isDirNameValid"
|
||||
:disabled="isLoading || !newDirName || !isDirNameValid"
|
||||
>
|
||||
{{ $t("general.create") }}
|
||||
</button>
|
||||
@@ -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
|
||||
|
||||
@@ -40,39 +40,12 @@
|
||||
<div class="upload-prompt-container">
|
||||
<i v-if="files.length === 0" class="material-symbols">cloud_upload</i>
|
||||
<p v-if="files.length === 0">{{ $t("prompts.dragAndDrop") }}</p>
|
||||
<div class="button-group">
|
||||
<button
|
||||
type="button"
|
||||
@click="triggerFilePicker"
|
||||
class="button button--flat"
|
||||
>
|
||||
{{ $t("general.file") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style="margin-left: 1em"
|
||||
@click="triggerFolderPicker"
|
||||
class="button button--flat"
|
||||
>
|
||||
{{ $t("general.folder") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style="margin-left: 1em"
|
||||
@click="triggerCameraPicker"
|
||||
class="button button--flat"
|
||||
>
|
||||
{{ $t("general.camera") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style="margin-left: 1em"
|
||||
@click="triggerVideoPicker"
|
||||
class="button button--flat"
|
||||
>
|
||||
{{ $t("general.video") }}
|
||||
</button>
|
||||
</div>
|
||||
<ButtonGroup
|
||||
class="upload-action-buttons"
|
||||
:buttons="uploadActionButtons"
|
||||
action-mode
|
||||
@button-clicked="onUploadAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.settings-group-title) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.upload-prompt {
|
||||
text-align: center;
|
||||
padding: 2em;
|
||||
@@ -734,6 +728,13 @@ export default {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.upload-action-buttons {
|
||||
margin: 0.5em 0 0;
|
||||
}
|
||||
|
||||
.upload-prompt i {
|
||||
|
||||
@@ -170,6 +170,7 @@ body.rtl .listing-items {
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
transition: all 0.25s ease-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.listing-items.gallery .listing-item .text {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"discardAndExit": "Verwerfen und Beenden",
|
||||
"saveAndExit": "Speichern und Beenden",
|
||||
"keepEditing": "Weiter bearbeiten",
|
||||
"closeAnyway": "Jedenfalls schließen",
|
||||
"closeAnyway": "Trotzdem schließen",
|
||||
"watchFile": "Datei beobachten",
|
||||
"showMore": "Erweiterte Optionen anzeigen",
|
||||
"showLess": "Erweiterte Optionen ausblenden",
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// eventBus.ts
|
||||
class EventBus extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
this.dispatchEvent(new CustomEvent(event, { detail: data }));
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
const wrapper = (e) => callback(e.detail);
|
||||
|
||||
// Store the wrapper so we can remove it later
|
||||
if (!this.listeners.has(callback)) {
|
||||
this.listeners.set(callback, new Map());
|
||||
}
|
||||
this.listeners.get(callback).set(event, wrapper);
|
||||
|
||||
this.addEventListener(event, wrapper);
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if (this.listeners.has(callback)) {
|
||||
const eventMap = this.listeners.get(callback);
|
||||
if (eventMap.has(event)) {
|
||||
const wrapper = eventMap.get(event);
|
||||
this.removeEventListener(event, wrapper);
|
||||
eventMap.delete(event);
|
||||
|
||||
// Clean up if no more events for this callback
|
||||
if (eventMap.size === 0) {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus();
|
||||
|
||||
export function emitStateChanged() {
|
||||
eventBus.emit('stateChanged');
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
type EventCallback = (data?: unknown) => void;
|
||||
|
||||
class EventBus extends EventTarget {
|
||||
private listeners = new Map<EventCallback, Map<string, EventListener>>();
|
||||
|
||||
emit(event: string, data?: unknown) {
|
||||
this.dispatchEvent(new CustomEvent(event, { detail: data }));
|
||||
}
|
||||
|
||||
on(event: string, callback: EventCallback) {
|
||||
if (this.listeners.get(callback)?.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = (e: Event) => callback((e as CustomEvent).detail);
|
||||
|
||||
// Store the wrapper so we can remove it later
|
||||
if (!this.listeners.has(callback)) {
|
||||
this.listeners.set(callback, new Map());
|
||||
}
|
||||
this.listeners.get(callback)!.set(event, wrapper);
|
||||
|
||||
this.addEventListener(event, wrapper);
|
||||
}
|
||||
|
||||
off(event: string, callback: EventCallback) {
|
||||
const eventMap = this.listeners.get(callback);
|
||||
if (eventMap?.has(event)) {
|
||||
const wrapper = eventMap.get(event)!;
|
||||
this.removeEventListener(event, wrapper);
|
||||
eventMap.delete(event);
|
||||
|
||||
// Clean up if no more events for this callback
|
||||
if (eventMap.size === 0) {
|
||||
this.listeners.delete(callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus();
|
||||
|
||||
export function emitStateChanged() {
|
||||
eventBus.emit('stateChanged');
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
import { detectLocale } from '@/i18n';
|
||||
import { mutations, state } from '@/store';
|
||||
import { mutations } from './mutations';
|
||||
import { state } from './state';
|
||||
import { url } from '@/utils';
|
||||
import { globalVars, previewViews, tools } from '@/utils/constants';
|
||||
import { getFileExtension } from '@/utils/files.js';
|
||||
import { getTypeInfo, isRichTextPreviewMimeType } from '@/utils/mimetype';
|
||||
import { getTypeInfo, isHtmlMimeType, isRichTextPreviewMimeType } from '@/utils/mimetype';
|
||||
import { fromNow } from '@/utils/moment';
|
||||
import { getNestedProperty, getObjectProperty } from '@/utils/object.js';
|
||||
import { buildItemUrl, removeLeadingSlash, removePrefix } from '@/utils/url.js';
|
||||
import type { DisplayPreference, FileListItem } from './types';
|
||||
|
||||
export const getters = {
|
||||
displayPreferenceFor: (source, path) => {
|
||||
displayPreferenceFor: (source: string, path: string): DisplayPreference | null => {
|
||||
const sourceKey = getters.isShare() ? getters.currentHash() : source;
|
||||
if (!sourceKey || !path) {
|
||||
return null;
|
||||
}
|
||||
return getNestedProperty(state.displayPreferences, sourceKey, path) || null;
|
||||
return (getNestedProperty(state.displayPreferences, sourceKey, path) as DisplayPreference) || null;
|
||||
},
|
||||
eventTheme: () => {
|
||||
if (getters.isShare()) {
|
||||
@@ -50,7 +52,10 @@ export const getters = {
|
||||
},
|
||||
isScrollable: () => {
|
||||
if (getters.currentView() === 'markdownViewer') {
|
||||
return true
|
||||
if (isHtmlMimeType(state.req?.type)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (getters.isPreviewView()) {
|
||||
return false
|
||||
@@ -193,15 +198,25 @@ export const getters = {
|
||||
isListing: () => getters.isFiles() || (getters.isShare() && state.req.type === 'directory'),
|
||||
selectedCount: () =>
|
||||
Array.isArray(state.selected) ? state.selected.length : 0,
|
||||
getFirstSelected: () => typeof(state.selected[0]) === 'number' ? state.req.items[state.selected[0]] : state.selected[0],
|
||||
getFirstSelected: () => {
|
||||
const first = state.selected[0];
|
||||
// eslint-disable-next-line security/detect-object-injection -- first is a numeric array index, not a property lookup
|
||||
return typeof first === 'number' ? state.req.items[first] : first;
|
||||
},
|
||||
isSingleFileSelected: () =>
|
||||
getters.selectedCount() === 1 &&
|
||||
getters.getFirstSelected()?.type !== 'directory',
|
||||
selectedDownloadUrl () {
|
||||
if (!state.selected || state.selected.length === 0) return "";
|
||||
|
||||
if (state.isSearchActive) {
|
||||
return buildItemUrl(state.selected[0].source, state.selected[0].path)
|
||||
const first = state.selected[0] as FileListItem;
|
||||
return buildItemUrl(first.source, first.path)
|
||||
}
|
||||
return buildItemUrl(state.req.items[state.selected[0]].source, state.req.items[state.selected[0]].path)
|
||||
const first = state.selected[0] as number;
|
||||
// eslint-disable-next-line security/detect-object-injection -- first is a numeric array index, not a property lookup
|
||||
const item = state.req.items[first];
|
||||
return item ? buildItemUrl(item.source, item.path) : "";
|
||||
},
|
||||
reqNumDirs: () => {
|
||||
let dirCount = 0
|
||||
@@ -248,7 +263,8 @@ export const getters = {
|
||||
if (item.type === 'directory') {
|
||||
dirs.push(item);
|
||||
} else {
|
||||
item.Path = state.req.path;
|
||||
// Pre-existing: capitalized "Path" (not FileListItem's "path"), never read elsewhere. Left as-is.
|
||||
(item as FileListItem & { Path?: string }).Path = state.req.path;
|
||||
files.push(item);
|
||||
}
|
||||
}
|
||||
@@ -336,7 +352,7 @@ export const getters = {
|
||||
isShare: () => {
|
||||
return getters.shareHash() !== ""
|
||||
},
|
||||
currentView: () => {
|
||||
currentView: (): string => {
|
||||
if (state.navigation.isTransitioning) return 'loading';
|
||||
const pathname = getters.routePath();
|
||||
if (!state.user || state.user?.username === "") return 'login';
|
||||
@@ -394,8 +410,8 @@ export const getters = {
|
||||
return Math.ceil((sum / totalSize) * 100)
|
||||
},
|
||||
filesInUploadCount: () => {
|
||||
const uploadsCount = state.upload.length
|
||||
const queueCount = state.queue.length
|
||||
const uploadsCount = Object.keys(state.upload.uploads || {}).length
|
||||
const queueCount = state.upload.queue?.length || 0
|
||||
return uploadsCount + queueCount
|
||||
},
|
||||
currentPrompt: () => {
|
||||
@@ -414,7 +430,7 @@ export const getters = {
|
||||
return ""
|
||||
}
|
||||
// Check if the name property is a string
|
||||
const lastPrompt = state.prompts[state.prompts.length - 1]
|
||||
const lastPrompt = state.prompts[state.prompts.length - 1] as { name?: string };
|
||||
if (typeof lastPrompt?.name !== 'string') {
|
||||
return ""
|
||||
}
|
||||
@@ -433,14 +449,15 @@ export const getters = {
|
||||
const files = []
|
||||
|
||||
for (const index of Object.keys(state.upload.uploads)) {
|
||||
const upload = getObjectProperty(state.upload.uploads, index)
|
||||
const upload = getObjectProperty(state.upload.uploads, index) as
|
||||
{ id: string | number; type: string; file: { name: string; type: string } } | undefined
|
||||
if (!upload) continue
|
||||
const id = upload.id
|
||||
const type = upload.type
|
||||
const name = upload.file.name
|
||||
const size = getObjectProperty(state.upload.sizes, id) ?? 0 // Default to 0 if size is undefined
|
||||
const size = (getObjectProperty(state.upload.sizes, id) as number | undefined) ?? 0 // Default to 0 if size is undefined
|
||||
const isDir = upload.file.type === 'directory'
|
||||
const loaded = getObjectProperty(state.upload.progress, id) || 0 // Default to 0 if progress is undefined
|
||||
const loaded = (getObjectProperty(state.upload.progress, id) as number | undefined) || 0 // Default to 0 if progress is undefined
|
||||
const safeSize = size > 0 ? size : 1
|
||||
const progress = isDir
|
||||
? 100
|
||||
@@ -488,6 +505,12 @@ export const getters = {
|
||||
}
|
||||
return false
|
||||
},
|
||||
shouldFetchFileContent: (fileInfo: { name: string; source?: string; onlyOfficeId?: string }) => {
|
||||
if (getters.fileViewingDisabled(fileInfo.name)) return false
|
||||
if (fileInfo.onlyOfficeId && !getters.officeViewingDisabled(fileInfo.name)) return false
|
||||
if (!getters.sourcePermissions(fileInfo.source).download) return false
|
||||
return true
|
||||
},
|
||||
anonymous: () => {
|
||||
return {
|
||||
id: 0,
|
||||
@@ -600,12 +623,17 @@ export const getters = {
|
||||
if (getters.isShare() && state.shareInfo.shareType === "upload") {
|
||||
return false;
|
||||
}
|
||||
const isAdvancedSearchRoute = (state.route.path || "").startsWith("/tools/advancedSearch");
|
||||
return getters.currentView() === "listingView" || getters.isEditorOrMarkdownView() || isAdvancedSearchRoute;
|
||||
return (
|
||||
getters.currentView() === "listingView" ||
|
||||
getters.isEditorOrMarkdownView() ||
|
||||
getters.currentTool()?.component === "AdvancedSearch"
|
||||
);
|
||||
},
|
||||
showGallerySizeSlider: () => {
|
||||
const isAdvancedSearchRoute = (state.route.path || "").startsWith("/tools/advancedSearch");
|
||||
return getters.currentView() === "listingView" || isAdvancedSearchRoute;
|
||||
return (
|
||||
getters.currentView() === "listingView" ||
|
||||
getters.currentTool()?.component === "AdvancedSearch"
|
||||
);
|
||||
},
|
||||
globalPermissions: () => {
|
||||
if (getters.isShare()) {
|
||||
@@ -626,7 +654,7 @@ export const getters = {
|
||||
archive: !!globalPerms.archive,
|
||||
};
|
||||
},
|
||||
sourcePermissions: (source) => {
|
||||
sourcePermissions: (source?: string) => {
|
||||
if (getters.isShare()) {
|
||||
return {
|
||||
view: !state.shareInfo?.disableFileViewer,
|
||||
@@ -1,15 +1,10 @@
|
||||
// store/index.ts
|
||||
import { getters } from "./getters.js";
|
||||
import { mutations } from "./mutations.js";
|
||||
import { state } from "./state.js";
|
||||
|
||||
import type { StoreState } from "./types";
|
||||
|
||||
// Type assertion to tell TypeScript about the actual structure
|
||||
const typedState = state as StoreState;
|
||||
import { getters } from "./getters";
|
||||
import { mutations } from "./mutations";
|
||||
import { state } from "./state";
|
||||
|
||||
export {
|
||||
getters,
|
||||
mutations,
|
||||
typedState as state,
|
||||
state,
|
||||
};
|
||||
@@ -10,8 +10,9 @@ import { getObjectProperty, setObjectProperty, omitObjectProperty } from '@/util
|
||||
import { sortedItems } from "@/utils/sort.js";
|
||||
import { updateManifestLink } from "@/utils/pwaManifest";
|
||||
import { emitStateChanged } from './eventBus';
|
||||
import { getters } from "./getters.js";
|
||||
import { state } from "./state.js";
|
||||
import { getters } from "./getters";
|
||||
import { state } from "./state";
|
||||
import type { SourceInfo, SourceInfoUpdate, UserObject } from "./types";
|
||||
|
||||
export const mutations = {
|
||||
disableEventThemes: () => {
|
||||
@@ -121,27 +122,27 @@ export const mutations = {
|
||||
},
|
||||
updateSource: (sourcename, value) => {
|
||||
if (getObjectProperty(state.sources.info, sourcename)) {
|
||||
state.sources.info = setObjectProperty(state.sources.info, sourcename, value);
|
||||
state.sources.info = setObjectProperty(state.sources.info, sourcename, value) as Record<string, SourceInfo>;
|
||||
}
|
||||
emitStateChanged();
|
||||
},
|
||||
updateSourceInfo: (value) => {
|
||||
updateSourceInfo: (value: Record<string, SourceInfoUpdate> | "error") => {
|
||||
if (value === "error") {
|
||||
state.realtimeActive = false;
|
||||
let info = state.sources.info;
|
||||
for (const k of Object.keys(info)) {
|
||||
info = setObjectProperty(info, k, { ...getObjectProperty(info, k), status: "error" });
|
||||
info = setObjectProperty(info, k, { ...(getObjectProperty(info, k) as SourceInfo), status: "error" }) as Record<string, SourceInfo>;
|
||||
}
|
||||
state.sources.info = info;
|
||||
} else {
|
||||
let info = state.sources.info;
|
||||
let hasAny = false;
|
||||
for (const [k, source] of Object.entries(value)) {
|
||||
const existing = getObjectProperty(info, k);
|
||||
const existing = getObjectProperty(info, k) as SourceInfo | undefined;
|
||||
if (existing) {
|
||||
const used = Number(source.used) || 0;
|
||||
const total = Number(source.total) || 0;
|
||||
const updated = {
|
||||
const updated: SourceInfo = {
|
||||
...existing,
|
||||
used,
|
||||
usedAlt: source.usedAlt || 0,
|
||||
@@ -159,7 +160,7 @@ export const mutations = {
|
||||
readOnly: source.readOnly || false,
|
||||
private: source.private || false,
|
||||
};
|
||||
info = setObjectProperty(info, k, updated);
|
||||
info = setObjectProperty(info, k, updated) as Record<string, SourceInfo>;
|
||||
if (updated.total > 0 || updated.used > 0 || updated.usedAlt > 0) hasAny = true;
|
||||
}
|
||||
}
|
||||
@@ -195,7 +196,13 @@ export const mutations = {
|
||||
) {
|
||||
currentSource = state.sources.current;
|
||||
}
|
||||
const sources = {info: {}, current: currentSource, count: user.scopes.length};
|
||||
const sources: {
|
||||
info: Record<string, SourceInfo>;
|
||||
current: string;
|
||||
count: number;
|
||||
hasSourceInfo: boolean;
|
||||
defaultSource?: string;
|
||||
} = { info: {}, current: currentSource, count: user.scopes.length, hasSourceInfo: false };
|
||||
for (const source of user.scopes) {
|
||||
const prev = prevInfo[source.name];
|
||||
const merge = Boolean(prev);
|
||||
@@ -331,7 +338,9 @@ export const mutations = {
|
||||
if (idx === -1) return;
|
||||
state.prompts.splice(idx, 1);
|
||||
}
|
||||
if (state.prompts.length === 0 && !state.stickySidebar) {
|
||||
// Pre-existing: no top-level state.stickySidebar (only state.user.stickySidebar), so this
|
||||
// is always truthy-negated. Left as-is; see PR description.
|
||||
if (state.prompts.length === 0 && !(state as unknown as { stickySidebar?: boolean }).stickySidebar) {
|
||||
state.showSidebar = false;
|
||||
}
|
||||
emitStateChanged();
|
||||
@@ -419,7 +428,7 @@ export const mutations = {
|
||||
}
|
||||
await setLocale(value.locale);
|
||||
state.user = value;
|
||||
state.user.sorting = {};
|
||||
state.user.sorting = {} as { by: string; asc: boolean };
|
||||
state.user.sorting.by = "name";
|
||||
state.user.sorting.asc = true;
|
||||
|
||||
@@ -467,7 +476,7 @@ export const mutations = {
|
||||
|
||||
if (!isAnonymous) {
|
||||
localStorage.setItem(`ViewMode_${encoded}`, viewMode);
|
||||
localStorage.setItem(`GallerySize_${encoded}`, gallerySize);
|
||||
localStorage.setItem(`GallerySize_${encoded}`, String(gallerySize));
|
||||
}
|
||||
|
||||
// Load display preferences for the current user
|
||||
@@ -591,7 +600,7 @@ export const mutations = {
|
||||
|
||||
// Initialize state.user if it's null
|
||||
if (!state.user) {
|
||||
state.user = {};
|
||||
state.user = {} as UserObject;
|
||||
}
|
||||
// Store previous state for comparison
|
||||
const previousUser = { ...state.user };
|
||||
@@ -780,7 +789,7 @@ export const mutations = {
|
||||
},
|
||||
updateListingSortConfig: ({ field, asc }) => {
|
||||
if (!state.user.sorting) {
|
||||
state.user.sorting = {};
|
||||
state.user.sorting = {} as { by: string; asc: boolean };
|
||||
}
|
||||
state.user.sorting.by = field;
|
||||
state.user.sorting.asc = asc;
|
||||
@@ -878,7 +887,7 @@ export const mutations = {
|
||||
let sourceLevel = getObjectProperty(prefs, source);
|
||||
if (!sourceLevel) sourceLevel = {};
|
||||
|
||||
let pathLevel = getObjectProperty(sourceLevel, path);
|
||||
let pathLevel = getObjectProperty(sourceLevel, path) as Record<string, unknown> | undefined;
|
||||
if (!pathLevel) pathLevel = {};
|
||||
|
||||
const newPathLevel = { ...pathLevel, ...payload };
|
||||
@@ -978,13 +987,18 @@ export const mutations = {
|
||||
|
||||
// Auto-show navigation when it's first set up (timer tracked so new opens clear it)
|
||||
if (state.navigation.enabled && (state.navigation.previousLink || state.navigation.nextLink)) {
|
||||
mutations.setNavigationShow(true);
|
||||
const hideTimer = setTimeout(() => {
|
||||
if (!state.navigation.hoverNav) {
|
||||
mutations.setNavigationShow(false);
|
||||
}
|
||||
}, 3000);
|
||||
mutations.setNavigationTimeout(hideTimer);
|
||||
const isImage = getTypeInfo(currentItem.type).simpleType === 'image';
|
||||
if (isImage) {
|
||||
mutations.showNavigationChromePersistent();
|
||||
} else {
|
||||
mutations.setNavigationShow(true);
|
||||
const hideTimer = setTimeout(() => {
|
||||
if (!state.navigation.hoverNav) {
|
||||
mutations.setNavigationShow(false);
|
||||
}
|
||||
}, 3000);
|
||||
mutations.setNavigationTimeout(hideTimer);
|
||||
}
|
||||
}
|
||||
},
|
||||
getPrefetchUrl: (item) => {
|
||||
@@ -1013,6 +1027,20 @@ export const mutations = {
|
||||
state.navigation.show = show;
|
||||
emitStateChanged();
|
||||
},
|
||||
showNavigationChromePersistent: () => {
|
||||
if (!state.navigation.enabled) {
|
||||
return;
|
||||
}
|
||||
mutations.clearNavigationTimeout();
|
||||
mutations.setNavigationShow(true);
|
||||
},
|
||||
toggleNavigationChrome: () => {
|
||||
if (!state.navigation.enabled) {
|
||||
return;
|
||||
}
|
||||
mutations.clearNavigationTimeout();
|
||||
mutations.setNavigationShow(!state.navigation.show);
|
||||
},
|
||||
/** Briefly reveal prev/next chrome (e.g. edge tap on video while gestures capture the event). */
|
||||
peekNavigationChrome: (side = null) => {
|
||||
if (!state.navigation.enabled) {
|
||||
@@ -1034,6 +1062,10 @@ export const mutations = {
|
||||
if (!side && !hasPrevious && !hasNext) {
|
||||
return;
|
||||
}
|
||||
if (getters.previewType() === 'image') {
|
||||
mutations.showNavigationChromePersistent();
|
||||
return;
|
||||
}
|
||||
mutations.setNavigationShow(true);
|
||||
mutations.clearNavigationTimeout();
|
||||
const hideTimer = setTimeout(() => {
|
||||
@@ -1051,10 +1083,7 @@ export const mutations = {
|
||||
state.navigation.hoverNav = hover;
|
||||
emitStateChanged();
|
||||
},
|
||||
/**
|
||||
* @param {{ kind?: null | 'previous' | 'next' | 'close', commitReady?: boolean, flashClose?: boolean }} [payload]
|
||||
*/
|
||||
setNavigationGestureHint: (payload = {}) => {
|
||||
setNavigationGestureHint: (payload: { kind?: null | 'previous' | 'next' | 'close', commitReady?: boolean, flashClose?: boolean } = {}) => {
|
||||
const kind = payload.kind ?? null;
|
||||
const commitReady = !!payload.commitReady;
|
||||
const flashClose = !!payload.flashClose;
|
||||
@@ -1,7 +1,8 @@
|
||||
import { reactive } from 'vue';
|
||||
import { detectLocale } from "@/i18n";
|
||||
import type { StoreState } from "./types";
|
||||
|
||||
export const state = reactive({
|
||||
export const state: StoreState = reactive({
|
||||
disableEventThemes: eventTheme(),
|
||||
tooltip: {
|
||||
show: false,
|
||||
@@ -109,7 +110,7 @@ export const state = reactive({
|
||||
items: [],
|
||||
},
|
||||
sharePassword: "",
|
||||
loading: [],
|
||||
loading: {}, // map of loadType -> true, see mutations.setLoading
|
||||
reload: false,
|
||||
selected: [],
|
||||
lastSelectedIndex: null,
|
||||
+119
-11
@@ -36,6 +36,9 @@ export interface ReqObject {
|
||||
subtitles?: unknown[];
|
||||
viewToken?: string;
|
||||
parentDirItems?: FileListItem[];
|
||||
onlyOfficeId?: string;
|
||||
hasUpdate?: boolean;
|
||||
metadata?: unknown;
|
||||
|
||||
// Directory listing properties
|
||||
listing?: FileListItem[];
|
||||
@@ -54,6 +57,89 @@ export interface ShareInfoObject {
|
||||
shareType: string;
|
||||
title: string;
|
||||
description: string;
|
||||
viewMode?: string;
|
||||
singleFileShare?: boolean;
|
||||
disableFileViewer?: boolean;
|
||||
allowModify?: boolean;
|
||||
allowCreate?: boolean;
|
||||
allowDelete?: boolean;
|
||||
disableDownload?: boolean;
|
||||
showHidden?: boolean;
|
||||
}
|
||||
|
||||
export interface Permissions {
|
||||
share?: boolean;
|
||||
modify?: boolean;
|
||||
create?: boolean;
|
||||
delete?: boolean;
|
||||
download?: boolean;
|
||||
admin?: boolean;
|
||||
api?: boolean;
|
||||
archive?: boolean;
|
||||
realtime?: boolean;
|
||||
}
|
||||
|
||||
export interface DisplayPreference {
|
||||
viewMode?: string;
|
||||
sorting?: {
|
||||
by: string;
|
||||
asc: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SidebarLink {
|
||||
category?: string;
|
||||
sourceName?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Prompt {
|
||||
id?: number;
|
||||
name?: string;
|
||||
parentId?: number;
|
||||
pinned?: boolean;
|
||||
confirm?: unknown;
|
||||
action?: unknown;
|
||||
props?: Record<string, unknown>;
|
||||
discard?: unknown;
|
||||
cancel?: unknown;
|
||||
}
|
||||
|
||||
export interface SourceInfo {
|
||||
pathPrefix?: string;
|
||||
used: number;
|
||||
total: number;
|
||||
usedAlt: number;
|
||||
usedPercentage: number;
|
||||
status: string;
|
||||
name: string;
|
||||
files: number;
|
||||
folders: number;
|
||||
lastIndex: number;
|
||||
quickScanDurationSeconds: number;
|
||||
fullScanDurationSeconds: number;
|
||||
complexity: number;
|
||||
scanners: unknown[];
|
||||
readOnly: boolean;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
/** Raw shape of a single source entry as sent by /api/settings/sources or SSE updates. */
|
||||
export interface SourceInfoUpdate {
|
||||
used?: number;
|
||||
total?: number;
|
||||
usedAlt?: number;
|
||||
status?: string;
|
||||
name?: string;
|
||||
numFiles?: number;
|
||||
numDirs?: number;
|
||||
lastIndexedUnixTime?: number;
|
||||
quickScanDurationSeconds?: number;
|
||||
fullScanDurationSeconds?: number;
|
||||
complexity?: number;
|
||||
scanners?: unknown[];
|
||||
readOnly?: boolean;
|
||||
private?: boolean;
|
||||
}
|
||||
|
||||
export interface SourceFilePermissions {
|
||||
@@ -78,6 +164,10 @@ export interface UserObject {
|
||||
models: boolean;
|
||||
popup: boolean;
|
||||
autoplayMedia?: boolean;
|
||||
office?: boolean;
|
||||
folder?: boolean;
|
||||
motionVideoPreview?: boolean;
|
||||
disableHideSidebar?: boolean;
|
||||
};
|
||||
loginType: string;
|
||||
username: string;
|
||||
@@ -91,19 +181,23 @@ export interface UserObject {
|
||||
viewMode: string;
|
||||
showHidden: boolean;
|
||||
scopes: UserScope[];
|
||||
permissions: unknown;
|
||||
permissions: Permissions;
|
||||
darkMode: boolean;
|
||||
disableSettings: boolean;
|
||||
debugOffice: boolean;
|
||||
preferEditorForMarkdown: boolean;
|
||||
showCopyPath?: boolean;
|
||||
hideFileExt?: string;
|
||||
themeColor?: string;
|
||||
sidebarLinks?: SidebarLink[];
|
||||
profile: {
|
||||
username: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
// Optional properties that may be added dynamically
|
||||
disableViewingExt?: string[];
|
||||
disableViewingExt?: string;
|
||||
disableOnlyOfficeExt?: string;
|
||||
displayNames?: string[];
|
||||
id?: number;
|
||||
password?: string;
|
||||
@@ -150,6 +244,7 @@ export interface StoreState {
|
||||
name: string;
|
||||
source: string;
|
||||
path: string;
|
||||
isShare?: boolean;
|
||||
};
|
||||
contextMenuHasItems: boolean;
|
||||
deletedItem: boolean;
|
||||
@@ -161,6 +256,11 @@ export interface StoreState {
|
||||
isSearchActive: boolean;
|
||||
showSidebar: boolean;
|
||||
displayPreferences: unknown;
|
||||
enforcedUserDefaults: {
|
||||
listing?: {
|
||||
viewMode?: string;
|
||||
};
|
||||
};
|
||||
usages: unknown;
|
||||
editor: unknown;
|
||||
editorDirty: boolean;
|
||||
@@ -185,11 +285,13 @@ export interface StoreState {
|
||||
};
|
||||
} | null;
|
||||
shareInfo: ShareInfoObject;
|
||||
seenUpdate?: string | null;
|
||||
sources: {
|
||||
current: string;
|
||||
count: number;
|
||||
hasSourceInfo: boolean;
|
||||
info: unknown;
|
||||
info: Record<string, SourceInfo>;
|
||||
defaultSource?: string;
|
||||
};
|
||||
user: UserObject;
|
||||
req: ReqObject;
|
||||
@@ -205,21 +307,23 @@ export interface StoreState {
|
||||
clipboard: {
|
||||
key: string;
|
||||
items: unknown[];
|
||||
path?: string;
|
||||
};
|
||||
sharePassword: string;
|
||||
loading: unknown[];
|
||||
loading: Record<string, unknown>;
|
||||
reload: boolean;
|
||||
selected: unknown[];
|
||||
selected: (number | FileListItem)[];
|
||||
lastSelectedIndex: number | null;
|
||||
multiple: boolean;
|
||||
upload: {
|
||||
uploads: unknown;
|
||||
uploads: Record<string, { id: string | number; type: string; file: { name: string; type: string; size?: number } }>;
|
||||
queue: unknown[];
|
||||
progress: unknown[];
|
||||
sizes: unknown[];
|
||||
progress: number[];
|
||||
sizes: number[];
|
||||
isUploading: boolean;
|
||||
};
|
||||
prompts: unknown[];
|
||||
prompts: Prompt[];
|
||||
promptIdCounter: number;
|
||||
show: unknown;
|
||||
showConfirm: unknown;
|
||||
route: RouteObject;
|
||||
@@ -245,10 +349,13 @@ export interface StoreState {
|
||||
nextLink: string;
|
||||
previousRaw: string;
|
||||
nextRaw: string;
|
||||
timeout: unknown;
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
enabled: boolean;
|
||||
isTransitioning: boolean;
|
||||
transitionStartTime: unknown;
|
||||
transitionStartTime: number | null;
|
||||
gestureHint: 'previous' | 'next' | 'close' | null;
|
||||
gestureHintCommitReady: boolean;
|
||||
gestureHintFlashClose: boolean;
|
||||
};
|
||||
playbackQueue: {
|
||||
queue: unknown[];
|
||||
@@ -256,6 +363,7 @@ export interface StoreState {
|
||||
mode: 'sequential' | 'shuffle';
|
||||
isPlaying: boolean;
|
||||
loop: 'off' | 'all' | 'single';
|
||||
shouldTogglePlayPause?: boolean;
|
||||
};
|
||||
notificationHistory: unknown[];
|
||||
sidebar: {
|
||||
|
||||
+134
-23
@@ -1,7 +1,73 @@
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { getCookie } from "@/utils/cookie.js";
|
||||
import { getApiPath } from "@/utils/url.js";
|
||||
|
||||
/** Session JWT: renew when within 30 minutes of expiry (former X-Renew-Token threshold). */
|
||||
export const SESSION_REFRESH_BEFORE_MS = 30 * 60 * 1000;
|
||||
|
||||
/** View grants: refresh when within 10 minutes of expiry. */
|
||||
export const VIEW_REFRESH_BEFORE_MS = 10 * 60 * 1000;
|
||||
|
||||
export const SESSION_COOKIE_NAME = "filebrowser_quantum_jwt";
|
||||
const KEEP_ALIVE_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
let keepAliveTimer = null;
|
||||
let renewInFlight = null;
|
||||
|
||||
/**
|
||||
* @param {number|null|undefined} expiresAtSeconds Unix expiry in seconds
|
||||
* @param {number} refreshBeforeMs Refresh this many ms before expiry
|
||||
* @returns {number} Milliseconds until a refresh should run (0 = refresh now)
|
||||
*/
|
||||
export function msUntilRefresh(expiresAtSeconds, refreshBeforeMs) {
|
||||
if (expiresAtSeconds === null || expiresAtSeconds === undefined || !Number.isFinite(expiresAtSeconds)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, expiresAtSeconds * 1000 - Date.now() - refreshBeforeMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|null|undefined} expiresAtSeconds Unix expiry in seconds
|
||||
* @param {number} refreshBeforeMs Refresh this many ms before expiry
|
||||
* @returns {boolean} True when the token should be refreshed now
|
||||
*/
|
||||
export function shouldRefreshBeforeExpiry(expiresAtSeconds, refreshBeforeMs) {
|
||||
return msUntilRefresh(expiresAtSeconds, refreshBeforeMs) === 0;
|
||||
}
|
||||
|
||||
function decodeBase64UrlJson(segment) {
|
||||
const padded = segment.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padLength = (4 - (padded.length % 4)) % 4;
|
||||
const base64 = padded + "=".repeat(padLength);
|
||||
return JSON.parse(atob(base64));
|
||||
}
|
||||
|
||||
/** Unix `exp` from the session JWT cookie, or null if missing/unreadable. */
|
||||
export function getSessionJwtExpiresAt() {
|
||||
const raw = getCookie(SESSION_COOKIE_NAME);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
// Cookie value may be URI-encoded depending on how it was set.
|
||||
let token = raw;
|
||||
try {
|
||||
token = decodeURIComponent(raw);
|
||||
} catch {
|
||||
// use raw
|
||||
}
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const payload = decodeBase64UrlJson(parts[1]);
|
||||
return typeof payload.exp === "number" ? payload.exp : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateLogin(isPublicRoute = false) {
|
||||
// Use direct fetch to avoid automatic logout on 401
|
||||
// Public routes (e.g. /public/share/...) use the public API base path
|
||||
@@ -25,7 +91,7 @@ export async function validateLogin(isPublicRoute = false) {
|
||||
!isPublicRoute &&
|
||||
document.cookie
|
||||
.split(";")
|
||||
.some((c) => c.trim().startsWith("filebrowser_quantum_jwt="))
|
||||
.some((c) => c.trim().startsWith(`${SESSION_COOKIE_NAME}=`))
|
||||
) {
|
||||
sessionExpired();
|
||||
}
|
||||
@@ -47,23 +113,74 @@ export async function validateLogin(isPublicRoute = false) {
|
||||
throw new Error(body);
|
||||
}
|
||||
}
|
||||
if (!isPublicRoute) {
|
||||
startSessionKeepAlive();
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie-based session renewal. Concurrent callers share one in-flight request
|
||||
* so mid-upload 401 retries and keep-alive do not stampede /auth/renew.
|
||||
*/
|
||||
export async function renew() {
|
||||
// Cookie-based renewal - no JWT parameter needed
|
||||
// Backend reads cookie, validates, and sets new cookie
|
||||
const apiPath = getApiPath("auth/renew")
|
||||
const res = await fetch(apiPath, {
|
||||
method: "POST",
|
||||
credentials: 'same-origin', // Cookie is sent automatically, backend renews it
|
||||
});
|
||||
const body = await res.text();
|
||||
if (res.status === 200) {
|
||||
mutations.setSession(generateRandomCode(8));
|
||||
// Backend sets the new cookie, no state management needed
|
||||
} else {
|
||||
if (renewInFlight) {
|
||||
return renewInFlight;
|
||||
}
|
||||
renewInFlight = (async () => {
|
||||
// Backend reads cookie, validates, and sets new cookie
|
||||
const apiPath = getApiPath("auth/renew");
|
||||
const res = await fetch(apiPath, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const body = await res.text();
|
||||
if (res.status === 200) {
|
||||
mutations.setSession(generateRandomCode(8));
|
||||
return;
|
||||
}
|
||||
throw new Error(body);
|
||||
})().finally(() => {
|
||||
renewInFlight = null;
|
||||
});
|
||||
return renewInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew the session JWT when it is missing or within the refresh window.
|
||||
* Concurrent callers share one in-flight renew; failures return false.
|
||||
*/
|
||||
export async function ensureSessionFresh(withinMs = SESSION_REFRESH_BEFORE_MS) {
|
||||
if (getters.isShare?.() && !getters.isLoggedIn?.()) {
|
||||
return false;
|
||||
}
|
||||
const exp = getSessionJwtExpiresAt();
|
||||
if (!shouldRefreshBeforeExpiry(exp, withinMs)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await renew();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("session keep-alive renew failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startSessionKeepAlive() {
|
||||
if (keepAliveTimer !== null) {
|
||||
return;
|
||||
}
|
||||
void ensureSessionFresh();
|
||||
keepAliveTimer = setInterval(() => {
|
||||
void ensureSessionFresh();
|
||||
}, KEEP_ALIVE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopSessionKeepAlive() {
|
||||
if (keepAliveTimer !== null) {
|
||||
clearInterval(keepAliveTimer);
|
||||
keepAliveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +196,7 @@ export function generateRandomCode(length) {
|
||||
}
|
||||
|
||||
export async function logout(redirectUrl) {
|
||||
stopSessionKeepAlive();
|
||||
try {
|
||||
const res = await fetch(getApiPath("auth/logout"), {
|
||||
method: "POST",
|
||||
@@ -91,7 +209,7 @@ export async function logout(redirectUrl) {
|
||||
destination = redirectUrl;
|
||||
}
|
||||
// Backend clears the cookie, but frontend does it as fail-safe cleanup
|
||||
document.cookie = "filebrowser_quantum_jwt=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/";
|
||||
document.cookie = `${SESSION_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/`;
|
||||
void mutations.setCurrentUser(null);
|
||||
// No need to clear state.jwt - cookie is the source of truth
|
||||
// Add a small delay to ensure cookie deletion completes before redirect
|
||||
@@ -115,8 +233,9 @@ export async function logout(redirectUrl) {
|
||||
// redirects to the login page so the user can re-authenticate, instead of being
|
||||
// stuck on a raw "token is expired" error.
|
||||
export function sessionExpired() {
|
||||
stopSessionKeepAlive();
|
||||
document.cookie =
|
||||
"filebrowser_quantum_jwt=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/";
|
||||
`${SESSION_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/`;
|
||||
void mutations.setCurrentUser(null);
|
||||
// Avoid a redirect loop if we're already on the login page.
|
||||
if (window.location.pathname.endsWith("/login")) {
|
||||
@@ -126,14 +245,6 @@ export function sessionExpired() {
|
||||
window.location.href = `${globalVars.baseURL}login?redirect=${encodeURIComponent(current)}`;
|
||||
}
|
||||
|
||||
// Helper function to retrieve the value of a specific cookie
|
||||
//function getCookie(name) {
|
||||
// return document.cookie
|
||||
// .split('; ')
|
||||
// .find(row => row.startsWith(name + '='))
|
||||
// ?.split('=')[1];
|
||||
//}
|
||||
|
||||
export async function initAuth() {
|
||||
if (!getters.isShare()) {
|
||||
await validateLogin();
|
||||
|
||||
@@ -4,7 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const { storeMock } = vi.hoisted(() => ({
|
||||
storeMock: {
|
||||
state: { sessionId: 'sess', user: null },
|
||||
getters: { isLoggedIn: vi.fn(() => true) },
|
||||
getters: {
|
||||
isLoggedIn: vi.fn(() => true),
|
||||
isShare: vi.fn(() => false),
|
||||
},
|
||||
mutations: { setCurrentUser: vi.fn(), setSession: vi.fn(), syncEnforcedUserDefaults: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -16,9 +19,21 @@ vi.mock('@/utils/url.js', () => ({
|
||||
`/api/${p}${params.username ? `?username=${params.username}` : ''}`,
|
||||
}));
|
||||
|
||||
import { validateLogin } from './auth.js';
|
||||
import {
|
||||
ensureSessionFresh,
|
||||
getSessionJwtExpiresAt,
|
||||
msUntilRefresh,
|
||||
renew,
|
||||
SESSION_COOKIE_NAME,
|
||||
SESSION_REFRESH_BEFORE_MS,
|
||||
shouldRefreshBeforeExpiry,
|
||||
startSessionKeepAlive,
|
||||
stopSessionKeepAlive,
|
||||
validateLogin,
|
||||
VIEW_REFRESH_BEFORE_MS,
|
||||
} from './auth.js';
|
||||
|
||||
const COOKIE = 'filebrowser_quantum_jwt';
|
||||
const COOKIE = SESSION_COOKIE_NAME;
|
||||
|
||||
function respond(status, body = '') {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
@@ -28,9 +43,22 @@ function respond(status, body = '') {
|
||||
});
|
||||
}
|
||||
|
||||
function makeJwt(expSeconds) {
|
||||
const header = btoa(JSON.stringify({ alg: 'none', typ: 'JWT' }))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
const payload = btoa(JSON.stringify({ exp: expSeconds }))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
return `${header}.${payload}.sig`;
|
||||
}
|
||||
|
||||
describe('validateLogin session-expiry handling', () => {
|
||||
beforeEach(() => {
|
||||
storeMock.mutations.setCurrentUser.mockClear();
|
||||
stopSessionKeepAlive();
|
||||
document.cookie = `${COOKIE}=stale; path=/`;
|
||||
// Stub navigation via Vitest's global-stub API (jsdom doesn't implement
|
||||
// real navigation). pathname+search feed the redirect; href captures it.
|
||||
@@ -38,6 +66,7 @@ describe('validateLogin session-expiry handling', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopSessionKeepAlive();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -76,3 +105,116 @@ describe('validateLogin session-expiry handling', () => {
|
||||
expect(window.location.href).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenRefresh helpers', () => {
|
||||
it('msUntilRefresh and shouldRefreshBeforeExpiry agree near expiry', () => {
|
||||
const soon = Math.floor(Date.now() / 1000) + 60;
|
||||
expect(shouldRefreshBeforeExpiry(soon, SESSION_REFRESH_BEFORE_MS)).toBe(true);
|
||||
expect(msUntilRefresh(soon, SESSION_REFRESH_BEFORE_MS)).toBe(0);
|
||||
|
||||
const later = Math.floor(Date.now() / 1000) + 60 * 60;
|
||||
expect(shouldRefreshBeforeExpiry(later, VIEW_REFRESH_BEFORE_MS)).toBe(false);
|
||||
expect(msUntilRefresh(later, VIEW_REFRESH_BEFORE_MS)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('treats missing expiry as needing refresh', () => {
|
||||
expect(shouldRefreshBeforeExpiry(null, SESSION_REFRESH_BEFORE_MS)).toBe(true);
|
||||
expect(msUntilRefresh(null, SESSION_REFRESH_BEFORE_MS)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session JWT keep-alive', () => {
|
||||
beforeEach(() => {
|
||||
stopSessionKeepAlive();
|
||||
storeMock.mutations.setSession.mockClear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopSessionKeepAlive();
|
||||
vi.useRealTimers();
|
||||
document.cookie = `${COOKIE}=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/`;
|
||||
});
|
||||
|
||||
it('parses exp from the session cookie JWT', () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
document.cookie = `${COOKIE}=${makeJwt(exp)}; path=/`;
|
||||
expect(getSessionJwtExpiresAt()).toBe(exp);
|
||||
});
|
||||
|
||||
it('ensureSessionFresh renews when JWT is within the refresh window', async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 60; // 1 minute left
|
||||
document.cookie = `${COOKIE}=${makeJwt(exp)}; path=/`;
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
text: async () => 'new-token',
|
||||
});
|
||||
const renewed = await ensureSessionFresh(SESSION_REFRESH_BEFORE_MS);
|
||||
expect(renewed).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/auth/renew',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
expect(storeMock.mutations.setSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensureSessionFresh skips renew when JWT is still fresh', async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 2 * 60 * 60; // 2 hours left
|
||||
document.cookie = `${COOKIE}=${makeJwt(exp)}; path=/`;
|
||||
global.fetch = vi.fn();
|
||||
const renewed = await ensureSessionFresh(SESSION_REFRESH_BEFORE_MS);
|
||||
expect(renewed).toBe(false);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensureSessionFresh returns false for joined callers when renew fails', async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 60;
|
||||
document.cookie = `${COOKIE}=${makeJwt(exp)}; path=/`;
|
||||
let release;
|
||||
const barrier = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
global.fetch = vi.fn().mockImplementation(async () => {
|
||||
await barrier;
|
||||
return { status: 500, text: async () => 'renew failed' };
|
||||
});
|
||||
|
||||
const first = ensureSessionFresh(SESSION_REFRESH_BEFORE_MS);
|
||||
const second = ensureSessionFresh(SESSION_REFRESH_BEFORE_MS);
|
||||
release();
|
||||
expect(await first).toBe(false);
|
||||
expect(await second).toBe(false);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renew single-flights concurrent callers', async () => {
|
||||
let release;
|
||||
const barrier = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
global.fetch = vi.fn().mockImplementation(async () => {
|
||||
await barrier;
|
||||
return { status: 200, text: async () => 'ok' };
|
||||
});
|
||||
|
||||
const first = renew();
|
||||
const second = renew();
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('startSessionKeepAlive ticks ensureSessionFresh on an interval', async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 60;
|
||||
document.cookie = `${COOKIE}=${makeJwt(exp)}; path=/`;
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
text: async () => 'new-token',
|
||||
});
|
||||
startSessionKeepAlive();
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(global.fetch.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
stopSessionKeepAlive();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeDocxHtml } from "./docxPreview";
|
||||
|
||||
describe("sanitizeDocxHtml", () => {
|
||||
it("strips javascript: hyperlinks", () => {
|
||||
const input =
|
||||
'<p><a href="javascript:alert(document.cookie)">CLICK HERE</a></p>';
|
||||
const output = sanitizeDocxHtml(input);
|
||||
expect(output).not.toContain("javascript:");
|
||||
expect(output).toContain("CLICK HERE");
|
||||
});
|
||||
|
||||
it("preserves normal formatting", () => {
|
||||
const input = "<p>Hello <strong>world</strong></p>";
|
||||
expect(sanitizeDocxHtml(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("preserves embedded data-uri images from mammoth", () => {
|
||||
const input = '<p><img src="data:image/png;base64,abc" alt="chart"></p>';
|
||||
const output = sanitizeDocxHtml(input);
|
||||
expect(output).toContain('src="data:image/png;base64,abc"');
|
||||
expect(output).toContain('alt="chart"');
|
||||
});
|
||||
|
||||
it("strips inline event handlers", () => {
|
||||
const input = '<p onclick="alert(1)">text</p>';
|
||||
const output = sanitizeDocxHtml(input);
|
||||
expect(output).not.toContain("onclick");
|
||||
expect(output).toContain("text");
|
||||
});
|
||||
|
||||
it("preserves https hyperlinks", () => {
|
||||
const input = '<p><a href="https://example.com/doc">link</a></p>';
|
||||
expect(sanitizeDocxHtml(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
/** Sanitize mammoth DOCX HTML before v-html rendering. */
|
||||
export function sanitizeDocxHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
const TYPE_AHEAD_RESET_MS = 1000;
|
||||
|
||||
const session = {
|
||||
prefix: '',
|
||||
lastKey: '',
|
||||
};
|
||||
|
||||
let resetTimeoutId = null;
|
||||
|
||||
export function normalizeTypeAheadName(name) {
|
||||
return String(name ?? '').normalize('NFC').toLocaleLowerCase();
|
||||
}
|
||||
|
||||
export function normalizeTypeAheadKey(key) {
|
||||
if (typeof key !== 'string' || !/^[a-z0-9]$/i.test(key)) {
|
||||
return '';
|
||||
}
|
||||
return key.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
export function getTypeAheadPrefix() {
|
||||
return session.prefix;
|
||||
}
|
||||
|
||||
export function isTypeAheadSessionActive() {
|
||||
return session.prefix !== '';
|
||||
}
|
||||
|
||||
export function resetTypeAheadSession() {
|
||||
if (resetTimeoutId !== null) {
|
||||
clearTimeout(resetTimeoutId);
|
||||
resetTimeoutId = null;
|
||||
}
|
||||
session.prefix = '';
|
||||
session.lastKey = '';
|
||||
}
|
||||
|
||||
export function scheduleTypeAheadReset(onExpire = resetTypeAheadSession) {
|
||||
if (resetTimeoutId !== null) {
|
||||
clearTimeout(resetTimeoutId);
|
||||
}
|
||||
resetTimeoutId = setTimeout(() => {
|
||||
resetTimeoutId = null;
|
||||
onExpire();
|
||||
}, TYPE_AHEAD_RESET_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {Array<{ name: string, index: number }>} items
|
||||
* @param {number | null} selectedIndex
|
||||
* @returns {{ prefix: string, matches: Array<{ name: string, index: number }>, nextPos: number, isSameKeyRepeat: boolean }}
|
||||
*/
|
||||
export function processTypeAheadKey(key, items, selectedIndex = null) {
|
||||
const lowerKey = normalizeTypeAheadKey(key);
|
||||
if (!lowerKey) {
|
||||
return { prefix: session.prefix, matches: [], nextPos: 0, isSameKeyRepeat: false };
|
||||
}
|
||||
|
||||
const hasActiveSession = session.prefix !== '';
|
||||
const isSameKeyRepeat = lowerKey === session.lastKey && hasActiveSession;
|
||||
|
||||
let prefix;
|
||||
if (isSameKeyRepeat) {
|
||||
prefix = session.prefix;
|
||||
} else if (!hasActiveSession) {
|
||||
prefix = lowerKey;
|
||||
} else {
|
||||
prefix = session.prefix + lowerKey;
|
||||
}
|
||||
|
||||
session.prefix = prefix;
|
||||
session.lastKey = lowerKey;
|
||||
scheduleTypeAheadReset();
|
||||
|
||||
const normalizedPrefix = normalizeTypeAheadName(prefix);
|
||||
const matches = items.filter((item) =>
|
||||
normalizeTypeAheadName(item.name).startsWith(normalizedPrefix)
|
||||
);
|
||||
|
||||
let nextPos = 0;
|
||||
if (isSameKeyRepeat && selectedIndex !== null) {
|
||||
const curPos = matches.findIndex((m) => m.index === selectedIndex);
|
||||
if (curPos !== -1) {
|
||||
nextPos = (curPos + 1) % matches.length;
|
||||
}
|
||||
}
|
||||
|
||||
return { prefix, matches, nextPos, isSameKeyRepeat };
|
||||
}
|
||||
|
||||
export { TYPE_AHEAD_RESET_MS };
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getTypeAheadPrefix,
|
||||
isTypeAheadSessionActive,
|
||||
normalizeTypeAheadName,
|
||||
processTypeAheadKey,
|
||||
resetTypeAheadSession,
|
||||
} from './listingTypeAhead.js';
|
||||
|
||||
const items = [
|
||||
{ name: 'kitchen.txt', index: 0 },
|
||||
{ name: 'notes.txt', index: 1 },
|
||||
{ name: 'WORK.doc', index: 2 },
|
||||
{ name: 'wombat', index: 3 },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
resetTypeAheadSession();
|
||||
});
|
||||
|
||||
describe('listingTypeAhead', () => {
|
||||
it('matches names case-insensitively', () => {
|
||||
expect(normalizeTypeAheadName('WORK.doc')).toBe('work.doc');
|
||||
});
|
||||
|
||||
it('accumulates a full prefix across rapid keys', () => {
|
||||
processTypeAheadKey('w', items);
|
||||
processTypeAheadKey('o', items);
|
||||
processTypeAheadKey('r', items);
|
||||
const result = processTypeAheadKey('k', items);
|
||||
expect(getTypeAheadPrefix()).toBe('work');
|
||||
expect(result.matches.map((m) => m.name)).toEqual(['WORK.doc']);
|
||||
});
|
||||
|
||||
it('keeps the buffer without changing selection when no items match', () => {
|
||||
processTypeAheadKey('w', items);
|
||||
processTypeAheadKey('o', items);
|
||||
processTypeAheadKey('r', items);
|
||||
const noMatch = processTypeAheadKey('x', items);
|
||||
expect(getTypeAheadPrefix()).toBe('worx');
|
||||
expect(noMatch.matches).toHaveLength(0);
|
||||
processTypeAheadKey('k', items);
|
||||
expect(getTypeAheadPrefix()).toBe('worxk');
|
||||
});
|
||||
|
||||
it('tracks whether a type-ahead session is active', () => {
|
||||
expect(isTypeAheadSessionActive()).toBe(false);
|
||||
processTypeAheadKey('w', items);
|
||||
expect(isTypeAheadSessionActive()).toBe(true);
|
||||
resetTypeAheadSession();
|
||||
expect(isTypeAheadSessionActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('cycles single-letter matches on repeated key presses', () => {
|
||||
const first = processTypeAheadKey('w', items, null);
|
||||
expect(first.matches.map((m) => m.name)).toEqual(['WORK.doc', 'wombat']);
|
||||
expect(first.nextPos).toBe(0);
|
||||
|
||||
const second = processTypeAheadKey('w', items, items[2].index);
|
||||
expect(second.prefix).toBe('w');
|
||||
expect(second.nextPos).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,27 @@ import {
|
||||
notifyUploadError,
|
||||
} from "@/utils/appNotifications";
|
||||
|
||||
/**
|
||||
* Upload session token for isolating concurrent uploads to the same path.
|
||||
* Prefers crypto.randomUUID(); falls back for older browsers / non-secure contexts.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function newUploadSessionId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
// RFC 4122 version 4 bits
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
return `upload-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulates every directory entry from a FileSystemDirectoryReader.
|
||||
*
|
||||
@@ -190,6 +211,7 @@ class UploadManager {
|
||||
|
||||
const upload = {
|
||||
id: this.nextId++,
|
||||
sessionId: newUploadSessionId(),
|
||||
name: dirName,
|
||||
size: 0,
|
||||
progress: 0,
|
||||
@@ -212,6 +234,7 @@ class UploadManager {
|
||||
const destinationPath = `${basePath}${relativePath}`;
|
||||
const upload = {
|
||||
id,
|
||||
sessionId: newUploadSessionId(),
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
@@ -355,14 +378,17 @@ class UploadManager {
|
||||
let promise;
|
||||
if (getters.isShare()) {
|
||||
promise = resourcesApi.postPublic(state.shareInfo?.hash, upload.path, upload.file, upload.overwrite, progress, {
|
||||
"X-File-Upload-Session": upload.sessionId,
|
||||
"X-File-Total-Size": upload.size,
|
||||
});
|
||||
} else {
|
||||
promise = resourcesApi.post(upload.source, upload.path, upload.file, upload.overwrite, progress, {
|
||||
"X-File-Upload-Session": upload.sessionId,
|
||||
"X-File-Total-Size": upload.size,
|
||||
});
|
||||
}
|
||||
|
||||
upload.xhrPromise = promise;
|
||||
upload.xhr = promise.xhr;
|
||||
await promise;
|
||||
|
||||
@@ -378,6 +404,7 @@ class UploadManager {
|
||||
} finally {
|
||||
this.activeUploads--;
|
||||
upload.xhr = null;
|
||||
upload.xhrPromise = null;
|
||||
void this.processQueue();
|
||||
}
|
||||
return;
|
||||
@@ -415,6 +442,7 @@ class UploadManager {
|
||||
upload.overwrite,
|
||||
chunkProgress,
|
||||
{
|
||||
"X-File-Upload-Session": upload.sessionId,
|
||||
"X-File-Chunk-Offset": upload.chunkOffset,
|
||||
"X-File-Total-Size": upload.size,
|
||||
}
|
||||
@@ -427,12 +455,14 @@ class UploadManager {
|
||||
upload.overwrite,
|
||||
chunkProgress,
|
||||
{
|
||||
"X-File-Upload-Session": upload.sessionId,
|
||||
"X-File-Chunk-Offset": upload.chunkOffset,
|
||||
"X-File-Total-Size": upload.size,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
upload.xhrPromise = promise;
|
||||
upload.xhr = promise.xhr;
|
||||
await promise;
|
||||
|
||||
@@ -461,6 +491,7 @@ class UploadManager {
|
||||
|
||||
this.activeUploads--;
|
||||
upload.xhr = null;
|
||||
upload.xhrPromise = null;
|
||||
void this.processQueue();
|
||||
}
|
||||
|
||||
@@ -489,9 +520,25 @@ class UploadManager {
|
||||
});
|
||||
}
|
||||
|
||||
activeXhr(upload) {
|
||||
return upload?.xhrPromise?.xhr || upload?.xhr || null;
|
||||
}
|
||||
|
||||
/** Abort the in-flight upload operation, including a pending 401 renew+retry. */
|
||||
abortUpload(upload) {
|
||||
if (upload?.xhrPromise && typeof upload.xhrPromise.abort === "function") {
|
||||
upload.xhrPromise.abort();
|
||||
return;
|
||||
}
|
||||
const xhr = this.activeXhr(upload);
|
||||
if (xhr && xhr.readyState !== XMLHttpRequest.DONE) {
|
||||
xhr.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async pause(id) {
|
||||
const upload = this.findById(id);
|
||||
if (upload?.status !== "uploading" || !upload.xhr) {
|
||||
if (upload?.status !== "uploading") {
|
||||
return;
|
||||
}
|
||||
if (upload.type !== "directory" && upload.size >= this.chunkSizeBytes()) {
|
||||
@@ -505,7 +552,7 @@ class UploadManager {
|
||||
console.warn("upload pause signal failed", e);
|
||||
}
|
||||
}
|
||||
upload.xhr.abort();
|
||||
this.abortUpload(upload);
|
||||
upload.status = "paused";
|
||||
this.clearProgressTimeout(id);
|
||||
}
|
||||
@@ -559,8 +606,8 @@ class UploadManager {
|
||||
|
||||
cancel(id) {
|
||||
const upload = this.findById(id);
|
||||
if (upload?.status === "uploading" && upload.xhr) {
|
||||
upload.xhr.abort();
|
||||
if (upload?.status === "uploading" || upload?.status === "paused") {
|
||||
this.abortUpload(upload);
|
||||
}
|
||||
this.clearProgressTimeout(id);
|
||||
const index = this.queue.findIndex((item) => item.id === id);
|
||||
@@ -575,10 +622,21 @@ class UploadManager {
|
||||
upload.overwrite = overwrite;
|
||||
upload.status = "pending";
|
||||
upload.connectionIssue = false; // Clear connection issue on retry
|
||||
if (upload.type !== 'directory') {
|
||||
upload.chunkOffset = 0; // Reset chunk offset for retries
|
||||
const isChunked =
|
||||
upload.type !== "directory" && upload.size >= this.chunkSizeBytes();
|
||||
const canResume = isChunked && upload.chunkOffset > 0;
|
||||
if (!canResume) {
|
||||
if (upload.type !== "directory") {
|
||||
upload.chunkOffset = 0;
|
||||
}
|
||||
upload.progress = 0;
|
||||
} else {
|
||||
// Resume chunked uploads from the last successful chunk.
|
||||
upload.progress =
|
||||
upload.size > 0
|
||||
? Math.round((upload.chunkOffset / upload.size) * 1000) / 10
|
||||
: 0;
|
||||
}
|
||||
upload.progress = 0;
|
||||
void this.processQueue();
|
||||
}
|
||||
}
|
||||
@@ -595,6 +653,9 @@ class UploadManager {
|
||||
}
|
||||
if (state.user.fileLoading?.clearAll) {
|
||||
if (status === "error" || status === "conflict" || status === "paused") {
|
||||
if (status === "paused") {
|
||||
this.abortUpload(upload);
|
||||
}
|
||||
this.clearProgressTimeout(upload.id);
|
||||
this.queue.splice(i, 1);
|
||||
}
|
||||
@@ -650,17 +711,36 @@ class UploadManager {
|
||||
upload.status = "conflict";
|
||||
upload.connectionIssue = false;
|
||||
} else if (err.message !== "Upload aborted") {
|
||||
upload.status = "error";
|
||||
|
||||
// Detect connection-related errors
|
||||
const isConnectionError =
|
||||
const isConnectionError =
|
||||
err.message === "Network error" ||
|
||||
err.message?.toLowerCase().includes("network") ||
|
||||
err.message?.toLowerCase().includes("timeout") ||
|
||||
err.message?.toLowerCase().includes("connection") ||
|
||||
err.message?.toLowerCase().includes("failed to fetch") ||
|
||||
!err.response; // No response usually means network issue
|
||||
|
||||
|
||||
const isChunked =
|
||||
upload.type !== "directory" && upload.size >= this.chunkSizeBytes();
|
||||
const canResumeChunked =
|
||||
isChunked && upload.chunkOffset > 0 && upload.chunkOffset < upload.size;
|
||||
|
||||
// Connection drops on chunked uploads: pause so Resume continues from last chunk
|
||||
// (same semantics as the stall timeout path).
|
||||
if (isConnectionError && canResumeChunked) {
|
||||
upload.status = "paused";
|
||||
upload.connectionIssue = true;
|
||||
upload.errorDetails =
|
||||
"Connection stalled - upload paused. Click resume to retry.";
|
||||
upload.progress =
|
||||
upload.size > 0
|
||||
? Math.round((upload.chunkOffset / upload.size) * 1000) / 10
|
||||
: 0;
|
||||
notifyUploadError(upload.name, upload.errorDetails);
|
||||
return;
|
||||
}
|
||||
|
||||
upload.status = "error";
|
||||
if (isConnectionError) {
|
||||
upload.connectionIssue = true;
|
||||
upload.errorDetails = `Connection error: ${this.formatErrorMessage(err)}. Click retry to resume.`;
|
||||
|
||||
@@ -49,21 +49,23 @@ async function fetchShareItemWithParent(sharePassword) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const content = !getters.fileViewingDisabled(file.name);
|
||||
let directoryPath = removeLastDir(state.shareInfo.subPath);
|
||||
if (!directoryPath || directoryPath === "") {
|
||||
directoryPath = "/";
|
||||
}
|
||||
const shouldFetchParent = directoryPath !== state.shareInfo.subPath;
|
||||
const promises = [
|
||||
resourcesApi.fetchFilesPublic(
|
||||
state.shareInfo.subPath,
|
||||
state.shareInfo.hash,
|
||||
sharePassword,
|
||||
content,
|
||||
false
|
||||
),
|
||||
];
|
||||
const contentPromise = getters.shouldFetchFileContent(file)
|
||||
? resourcesApi
|
||||
.fetchFilesPublic(
|
||||
state.shareInfo.subPath,
|
||||
state.shareInfo.hash,
|
||||
sharePassword,
|
||||
true,
|
||||
false
|
||||
)
|
||||
.catch(() => file)
|
||||
: Promise.resolve(file);
|
||||
const promises = [contentPromise];
|
||||
if (shouldFetchParent) {
|
||||
promises.push(
|
||||
resourcesApi
|
||||
@@ -87,13 +89,15 @@ async function fetchAuthItemWithParent(fetchSource, fetchPath) {
|
||||
if (res.type === "directory") {
|
||||
return res;
|
||||
}
|
||||
const content = !getters.fileViewingDisabled(res.name);
|
||||
let directoryPath = removeLastDir(res.path);
|
||||
if (!directoryPath || directoryPath === "") {
|
||||
directoryPath = "/";
|
||||
}
|
||||
const shouldFetchParent = directoryPath !== res.path;
|
||||
const promises = [resourcesApi.fetchFiles(res.source, res.path, content, false)];
|
||||
const contentPromise = getters.shouldFetchFileContent(res)
|
||||
? resourcesApi.fetchFiles(res.source, res.path, true, false).catch(() => res)
|
||||
: Promise.resolve(res);
|
||||
const promises = [contentPromise];
|
||||
if (shouldFetchParent) {
|
||||
promises.push(
|
||||
resourcesApi.fetchFiles(res.source, directoryPath, false, false).catch(() => null)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { defineComponent } from "vue";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { ensureViewToken, refreshViewToken, requestViewIdentity } from "@/api/viewToken.js";
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import { sanitizeDocxHtml } from "@/utils/docxPreview";
|
||||
import { removeLastDir } from "@/utils/url.js";
|
||||
|
||||
export default defineComponent({
|
||||
@@ -244,7 +245,7 @@ export default defineComponent({
|
||||
const mammoth = mammothModule.default ?? mammothModule;
|
||||
const { convertToHtml } = mammoth;
|
||||
const result = await convertToHtml({ arrayBuffer });
|
||||
this.docxHtml = result.value;
|
||||
this.docxHtml = sanitizeDocxHtml(result.value);
|
||||
} catch (e) {
|
||||
this.error = e.message || "An unknown error occurred.";
|
||||
} finally {
|
||||
|
||||
@@ -189,6 +189,11 @@ import { url } from "@/utils";
|
||||
import Item from "@/components/files/ListingItem.vue";
|
||||
import Upload from "@/components/prompts/Upload.vue";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import {
|
||||
isTypeAheadSessionActive,
|
||||
processTypeAheadKey,
|
||||
resetTypeAheadSession,
|
||||
} from "@/utils/listingTypeAhead.js";
|
||||
|
||||
export default {
|
||||
name: "listingView",
|
||||
@@ -519,6 +524,8 @@ export default {
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
resetTypeAheadSession();
|
||||
|
||||
if (this.resizeTimeout) {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
this.resizeTimeout = null;
|
||||
@@ -775,6 +782,16 @@ export default {
|
||||
if (state.isSearchActive || getters.currentView() !== "listingView" ||
|
||||
getters.currentPromptName() || (event.repeat && (!isArrowKey || altKey))) return;
|
||||
|
||||
if (
|
||||
(key === ' ' || key === '/') &&
|
||||
isTypeAheadSessionActive() &&
|
||||
!state.isSearchActive &&
|
||||
!getters.currentPromptName()
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const isAlphanumeric = /^[a-z0-9]$/i.test(key);
|
||||
const modifierKeys = ctrlKey || metaKey;
|
||||
if (isAlphanumeric && !modifierKeys && state.selected.length <= 1) {
|
||||
@@ -873,18 +890,10 @@ export default {
|
||||
}
|
||||
},
|
||||
alphanumericKeyPress(key) {
|
||||
const prefix = key.toLowerCase();
|
||||
const allItems = this.allItems;
|
||||
const matches = allItems.filter(item =>
|
||||
item.name.toLowerCase().startsWith(prefix)
|
||||
);
|
||||
if (matches.length === 0) return;
|
||||
|
||||
let nextPos = 0;
|
||||
if (state.selected.length === 1) {
|
||||
const curIdx = state.selected[0];
|
||||
const curPos = matches.findIndex(m => m.index === curIdx);
|
||||
if (curPos !== -1) nextPos = (curPos + 1) % matches.length;
|
||||
const selectedIndex = state.selected.length === 1 ? state.selected[0] : null;
|
||||
const { matches, nextPos } = processTypeAheadKey(key, this.allItems, selectedIndex);
|
||||
if (matches.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = matches.at(nextPos);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div id="markedown-viewer">
|
||||
<div
|
||||
id="markedown-viewer"
|
||||
:class="{ 'html-viewer-mode': isHtml }"
|
||||
:style="htmlViewerStyle"
|
||||
>
|
||||
<iframe
|
||||
v-if="isHtml"
|
||||
ref="viewer"
|
||||
@@ -13,7 +17,7 @@
|
||||
<div v-else class="markdown-content-container" :class="{ 'dark-mode': darkMode }">
|
||||
<div ref="viewer" v-html="renderedContent" class="markdown-content"></div>
|
||||
</div>
|
||||
<div class="spacer" :style="{ height: `${spaceForStatusBar}em` }"></div>
|
||||
<div v-if="!isHtml" class="spacer" :style="{ height: `${spaceForStatusBar}em` }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -337,6 +341,17 @@ export default {
|
||||
spaceForStatusBar() {
|
||||
return state.isMobile ? 3.1 : 3.5;
|
||||
},
|
||||
htmlViewerStyle() {
|
||||
if (!this.isHtml) {
|
||||
return undefined;
|
||||
}
|
||||
const statusBar = this.spaceForStatusBar;
|
||||
const height = `calc(100vh - 4em - ${statusBar}em - 0.5em)`;
|
||||
return {
|
||||
height,
|
||||
minHeight: height,
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.reinit();
|
||||
@@ -358,6 +373,15 @@ export default {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#markedown-viewer.html-viewer-mode {
|
||||
margin: 0.5em;
|
||||
width: calc(100% - 1em);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#markedown-viewer .markdown-content-container {
|
||||
background-color: var(--alt-background);
|
||||
border-radius: 1em;
|
||||
@@ -377,6 +401,13 @@ export default {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
#markedown-viewer.html-viewer-mode .html-content {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#markedown-viewer .html-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
@@ -483,12 +483,10 @@ export default {
|
||||
const blob = new Blob([vttContent], { type: "text/vtt" });
|
||||
const vttURL = URL.createObjectURL(blob);
|
||||
|
||||
const lang = (subtitleTrack.language ?? '').trim();
|
||||
subs.push({
|
||||
name: subtitleTrack.name,
|
||||
src: vttURL,
|
||||
// Empty srclang breaks Plyr language matching; use 'und' (undetermined) per BCP 47.
|
||||
language: lang || 'und',
|
||||
srclang: subtitleTrack.srclang || subtitleTrack.language,
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
playsinline
|
||||
>
|
||||
<track kind="captions" v-for="(sub, index) in subtitlesList" :key="index" :src="sub.src"
|
||||
:label="subtitleTrackLabel(sub)" :srclang="sub.language" />
|
||||
:label="sub.name || subtitleTrackLabel(sub)" :srclang="sub.srclang || sub.language" />
|
||||
</video>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
import { toolsApi } from "@/api";
|
||||
import router from "@/router";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import SettingsItem from "@/components/settings/SettingsItem.vue";
|
||||
@@ -511,6 +512,7 @@ export default {
|
||||
suppressRouteQueryNavigation: false,
|
||||
emptyCatalogHydrateTimer: null,
|
||||
advancedOptionsExpanded: false,
|
||||
refreshQueued: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -695,8 +697,18 @@ export default {
|
||||
|
||||
return styles;
|
||||
},
|
||||
reload() {
|
||||
return state.reload;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
reload(shouldReload) {
|
||||
if (!shouldReload || !this.isAdvancedSearchRoute || !this.searchExecuted) {
|
||||
return;
|
||||
}
|
||||
mutations.setReload(false);
|
||||
this.scheduleSearchRefresh();
|
||||
},
|
||||
termInputs: {
|
||||
deep: true,
|
||||
handler() {
|
||||
@@ -844,6 +856,10 @@ export default {
|
||||
};
|
||||
window.addEventListener("resize", this.resizeListener);
|
||||
|
||||
eventBus.on("itemsDeleted", this.handleSearchResultsChanged);
|
||||
eventBus.on("itemsMoved", this.handleSearchResultsChanged);
|
||||
eventBus.on("itemsRenamed", this.handleSearchResultsChanged);
|
||||
|
||||
if (state.req !== null && state.req !== undefined) {
|
||||
this.hadInitialReq = true;
|
||||
this.initialReqFrozen = JSON.stringify(state.req);
|
||||
@@ -874,6 +890,11 @@ export default {
|
||||
window.removeEventListener("resize", this.resizeListener);
|
||||
this.resizeListener = null;
|
||||
}
|
||||
|
||||
eventBus.off("itemsDeleted", this.handleSearchResultsChanged);
|
||||
eventBus.off("itemsMoved", this.handleSearchResultsChanged);
|
||||
eventBus.off("itemsRenamed", this.handleSearchResultsChanged);
|
||||
|
||||
if (!this.didApplySearchListing) {
|
||||
return;
|
||||
}
|
||||
@@ -884,6 +905,26 @@ export default {
|
||||
mutations.replaceRequest(JSON.parse(this.initialReqFrozen));
|
||||
},
|
||||
methods: {
|
||||
handleSearchResultsChanged(data) {
|
||||
if (!this.isAdvancedSearchRoute || !this.searchExecuted) {
|
||||
return;
|
||||
}
|
||||
if (data?.succeeded?.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.scheduleSearchRefresh();
|
||||
},
|
||||
scheduleSearchRefresh() {
|
||||
if (this.refreshQueued) {
|
||||
return;
|
||||
}
|
||||
this.refreshQueued = true;
|
||||
this.$nextTick(() => {
|
||||
this.refreshQueued = false;
|
||||
mutations.resetSelected();
|
||||
void this.runSearch();
|
||||
});
|
||||
},
|
||||
/** When no source is toggled on, enable `state.sources.current` if known in the catalogue. */
|
||||
applyDefaultCurrentSourceIfNone() {
|
||||
if (!this.isAdvancedSearchRoute) {
|
||||
|
||||
@@ -148,15 +148,19 @@ const EXPECTED_USER_DETAILS: UserExpectation[] = [
|
||||
lockPassword: false,
|
||||
},
|
||||
loginMethod: "Password",
|
||||
sources: sourcePermissionsForSources(
|
||||
["playwright + files", "docker"],
|
||||
GRAHAM_SOURCE_PERMS,
|
||||
),
|
||||
sources: {
|
||||
...sourcePermissionsForSources(
|
||||
["playwright + files", "docker"],
|
||||
GRAHAM_SOURCE_PERMS,
|
||||
),
|
||||
// defaultEnabled access is merged on startup (v2.0.1+) with source defaults.
|
||||
access: { ...DEFAULT_SOURCE_PERMS },
|
||||
},
|
||||
scopePaths: scopePathsForSources([
|
||||
["playwright + files", "/myfolder"],
|
||||
["docker", "/"],
|
||||
["access", "/"],
|
||||
]),
|
||||
absentSources: ["access"],
|
||||
},
|
||||
{
|
||||
username: "testuser1",
|
||||
@@ -677,7 +681,7 @@ test.describe("Migration fixture verification", () => {
|
||||
test.describe("non-admin migrated users", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test("graham login keeps scoped sources and file access", async ({
|
||||
test("graham login keeps scoped sources and gains defaultEnabled access", async ({
|
||||
page,
|
||||
checkForErrors,
|
||||
}) => {
|
||||
@@ -686,7 +690,8 @@ test.describe("Migration fixture verification", () => {
|
||||
|
||||
await expect(sourceSidebarLink(page, "playwright + files")).toBeVisible();
|
||||
await expect(sourceSidebarLink(page, "docker")).toBeVisible();
|
||||
await expect(sourceSidebarLink(page, "access")).toHaveCount(0);
|
||||
// access is defaultEnabled and is merged for existing users on startup (v2.0.1+).
|
||||
await expect(sourceSidebarLink(page, "access")).toBeVisible();
|
||||
|
||||
await expect(sourceSidebarLink(page, "playwright + files")).not.toHaveClass(/disabled/);
|
||||
await expect(sourceSidebarLink(page, "docker")).not.toHaveClass(/disabled/);
|
||||
|
||||
@@ -61,6 +61,9 @@ export default defineConfig(() => {
|
||||
},
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes("/src/store/")) {
|
||||
return "store";
|
||||
}
|
||||
if (id.includes("node_modules/highlight.js")) {
|
||||
return "highlightjs";
|
||||
}
|
||||
|
||||
@@ -18,19 +18,24 @@ PLAYWRIGHT_TEST ?= "settings"
|
||||
.PHONY: setup update build build-docker build-backend build-frontend dev run generate-docs
|
||||
.PHONY: lint-frontend lint-backend lint test test-backend test-frontend check-all
|
||||
.PHONY: check-translations sync-translations test-playwright run-proxy screenshots
|
||||
.PHONY: check-icons generate-icons sync-icons
|
||||
.PHONY: check-icons generate-icons sync-icons setup-gofitz-cgo
|
||||
|
||||
setup:
|
||||
echo "creating ./backend/test_config.yaml for local testing..."
|
||||
if [ ! -f backend/test_config.yaml ]; then \
|
||||
cp backend/config.yaml backend/test_config.yaml; \
|
||||
fi
|
||||
$(MAKE) setup-gofitz-cgo
|
||||
echo "installing backend tooling..."
|
||||
cd backend && go get tool
|
||||
cd backend/internal/web && mkdir -p embed dist && touch embed/.gitignore
|
||||
echo "installing npm requirements for frontend..."
|
||||
cd frontend && npm i
|
||||
|
||||
setup-gofitz-cgo:
|
||||
echo "linking go-fitz MuPDF headers for preview CGO..."
|
||||
cd backend && go run ./scripts/setup-gofitz-cgo
|
||||
|
||||
update:
|
||||
cd backend && go get -u ./... && go mod tidy
|
||||
cd frontend && npm update
|
||||
@@ -49,7 +54,7 @@ build-backend:
|
||||
@echo "✓ Backend built successfully"
|
||||
|
||||
# New dev target with hot-reloading for frontend and backend
|
||||
dev: generate-docs generate-icons
|
||||
dev: generate-docs generate-icons setup-gofitz-cgo
|
||||
@echo "Starting dev servers... Press Ctrl+C to stop."
|
||||
pkill -f '[t]est_config.yaml' || true
|
||||
pkill -f '[g]o tool air' || true
|
||||
@@ -60,7 +65,7 @@ dev: generate-docs generate-icons
|
||||
trap 'echo "Stopping..."; kill $$FRONTEND_PID $$BACKEND_PID 2>/dev/null; sleep 1; kill -9 $$FRONTEND_PID $$BACKEND_PID 2>/dev/null; exit 0' INT TERM; \
|
||||
wait $$FRONTEND_PID $$BACKEND_PID 2>/dev/null || true
|
||||
|
||||
run: build-frontend generate-docs
|
||||
run: build-frontend generate-docs setup-gofitz-cgo
|
||||
cd backend && go tool swag init --output swagger/docs
|
||||
@if [ "$$(uname)" = "Darwin" ]; then \
|
||||
sed -i '' '/func init/,+3d' backend/swagger/docs/docs.go; \
|
||||
|
||||
Reference in new issue
Block a user