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:
authored and GitHub committed 2026-08-15 11:06:07 -05:00
1 parent 29f9055db9
commit 3c8c02414e
100 files changed
+5906 -1135

No files matched your search

+34
View File
@@ -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.
+1
View File
@@ -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}' \
+1
View File
@@ -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 -1
View File
@@ -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"]
+8 -2
View File
@@ -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
+28 -8
View File
@@ -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
View File
@@ -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
}
+37 -20
View File
@@ -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) {
+6
View File
@@ -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"},
},
},
-10
View File
@@ -1,10 +0,0 @@
userDefaults:
preview:
image: true
video: true
audio: true
motionVideoPreview: true
office: true
popup: true
folder: true
models: true
+12 -14
View File
@@ -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
View File
@@ -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=
+23 -7
View File
@@ -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 {
+124
View File
@@ -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)
}
}
+54 -22
View File
@@ -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
}
+146 -7
View File
@@ -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")
}
}
+5
View File
@@ -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
+1
View File
@@ -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")
+37 -17
View File
@@ -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)
}
+100 -7
View File
@@ -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",
},
}
+24 -12
View File
@@ -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[:]...)
}
+13
View File
@@ -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)
+248
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
gofitzinclude
+22 -57
View File
@@ -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
}
+2 -1
View File
@@ -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
+7
View File
@@ -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"
+272
View File
@@ -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
}
+31 -21
View File
@@ -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)
}
+4 -3
View File
@@ -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
+33
View File
@@ -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 == "/" {
+67
View File
@@ -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)
}
}
+7 -3
View File
@@ -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
}
+2 -2
View File
@@ -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]
+2
View File
@@ -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:
+7 -7
View File
@@ -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
+169
View File
@@ -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)
+205 -76
View File
@@ -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)
}
}
+3 -1
View File
@@ -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
}
+186
View File
@@ -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)
}
}
+21 -3
View File
@@ -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 {
+88
View File
@@ -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
}
+98
View File
@@ -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
}
-3
View File
@@ -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>")
}
+2 -2
View File
@@ -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 {
+2
View File
@@ -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()
}
+97 -16
View File
@@ -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)
+29
View File
@@ -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)
}
}
+2 -2
View File
@@ -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()
+56 -17
View File
@@ -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 {
+168
View File
@@ -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)
}
}
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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
+304 -269
View File
File diff suppressed because it is too large. Load diff
+116 -48
View File
@@ -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.
+3 -17
View File
@@ -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());
+10 -4
View File
@@ -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;
}
+18 -2
View File
@@ -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;
+30 -2
View File
@@ -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;
+9
View File
@@ -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: {
+26
View File
@@ -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;
+26 -3
View File
@@ -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;
}
+30 -20
View File
@@ -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);
+55 -22
View File
@@ -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"));
}
}
},
},
+8 -2
View File
@@ -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
+43 -42
View File
@@ -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 {
+1
View File
@@ -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 {
+1 -1
View File
@@ -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",
-45
View File
@@ -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');
}
+45
View File
@@ -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,
+4 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+145 -3
View File
@@ -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();
});
});
+36
View File
@@ -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);
});
});
+6
View File
@@ -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);
}
+92
View File
@@ -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);
});
});
+91 -11
View File
@@ -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.`;
+16 -12
View File
@@ -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)
+2 -1
View File
@@ -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 {
+21 -12
View File
@@ -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);
+33 -2
View File
@@ -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;
+1 -3
View File
@@ -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(
+1 -1
View File
@@ -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/);
+3
View File
@@ -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";
}
+8 -3
View File
@@ -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; \