Compare commits

...
50 Commits
Author SHA1 Message Date
Graham Steffaniak 8415d5f1ff Fix install button (#3312) 2026-10-31 08:27:12 -05:00
Graham Steffaniak 85f934208c replace button with link 2026-10-31 08:20:44 -05:00
1022ecafef fix(frontend): address CodeRabbit follow-ups from #3276 and #3303 (#3311)
Co-authored-by: FandacqkaCorp <275647464+FandacqkaCorp@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 08:17:44 -05:00
Graham Steffaniak 062ac9fb2d Beta/v2.1.4 (#3307) 2026-10-30 19:14:33 -05:00
Graham SteffaniakandGraham Steffaniak cecdd79fcc Restrict Unix socket forwarded-header trust with peer credentials (#3309)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-30 18:40:10 -05:00
Graham Steffaniak ac50ecfd18 fix: CodeRabbit follow-ups from #3307 (quick-win bugfixes) (#3308) 2026-10-30 17:56:50 -05:00
Graham Steffaniak 71cc2f3f1b fixed linting 2026-10-30 16:30:00 -05:00
Graham Steffaniak 1b3f47f176 fix sensitive data GHSA-892f-9h5p-mfc8 (#3306) 2026-10-30 16:25:25 -05:00
Graham Steffaniak 219b64b5ec addressed deep copy issue 2026-10-30 15:37:45 -05:00
Graham Steffaniak 3ef4928ed7 fix: prevent visible border in generated maskable PWA icons (#3300) 2026-10-30 15:33:33 -05:00
Graham Steffaniak df0a5ad10c fix sensitive data GHSA-892f-9h5p-mfc8 2026-10-30 15:22:46 -05:00
Graham Steffaniak a43d0ab2d3 updated changelog 2026-10-30 15:05:34 -05:00
Graham Steffaniak 9de09e5ec8 feat(search): sidebar File actions and close button in advanced search (#3303) 2026-10-30 15:02:51 -05:00
Graham Steffaniak 9d38c686a1 updated changelog 2026-10-30 15:01:15 -05:00
Graham Steffaniak 1cfca112e4 fix empty view after login if source named 'Files' (#3304) 2026-10-30 14:51:04 -05:00
Graham Steffaniak f540e22dc9 fix(quotas): complete delete accounting for bulk and nested resets (#3305) 2026-10-30 13:26:38 -05:00
Graham Steffaniak dade856ebe fix(move-copy): require choosing a destination when none can be inferred (advanced search) (#3276) 2026-10-30 13:19:15 -05:00
Graham SteffaniakandCursor 2f1da21253 fix(quotas): complete delete accounting for bulk and nested resets
Follow-up to #3235 / #3163: reset accounted folder quotas inside deleted
directories without index lookups, wire the same prepare/apply path into
bulk and public delete handlers, and dedupe scope quota selection.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 13:04:16 -05:00
Kevin B. de8d2acd43 fix empty view after login 2026-10-30 13:04:15 -05:00
Graham Steffaniak 6375be202e fix: release nested folder quotas when deleting their ancestor (#3235) 2026-10-30 12:59:12 -05:00
FandacqkaCorpandClaude Sonnet 5.5 816b99cee0 feat(search): add a close button to advanced search
On tool pages the top bar button only toggles the sidebar, so the only ways
out of advanced search were the sidebar or the browser back button.

Add an icon-only grey X at the top-left of the advanced search page (first
element of the tools wrapper); the top bar button is left as it is. It clears
the selection and goes back to the page the search was opened from, or to
/files when there is none (e.g. the URL was opened directly).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 16:14:12 +01:00
FandacqkaCorpandClaude Sonnet 5.5 bf349a7dcd feat(search): show the sidebar File actions in advanced search
The sidebar File actions button only exists in the listing, so in advanced
search the actions on the selected results (select all, download, copy, move)
were only reachable through the context menu, which is hard to find, especially
on touch devices.

Show the same sidebar button in advanced search. There it opens the context
menu on the current selection instead of the create-only menu, and it doesn't
depend on create permissions (the menu already checks permissions per
source). The menu now always offers Select multiple in advanced search, so
single results can be selected when "open with a single click" is on, and hides
the create (+) toggle, since results have no single folder to create in.

The button still honours the "hide sidebar file actions" preference and isn't
shown in shares.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 15:55:48 +01:00
Matt Van Horn db6504bf80 fix: prevent visible border in generated maskable PWA icons
Maskable icon generation now alpha-composites the resized artwork over
the opaque background using `OverlayCenter`. Added tests covering
transparent padding and opaque edges, verifying the output dimensions,
opacity, and background pixels outside the artwork.

Fixes #3187
2026-10-30 03:59:21 -07:00
FandacqkaCorpandClaude Sonnet 5.5 2019d573f8 fix(move-copy): require choosing a destination when there is no source to fall back to
In advanced search the open request is the list of results and has no source.
The Copy/Move prompt then showed "Choose a source" but, if the button was
pressed without choosing anything, silently sent the items to the root of the
first selected item's source.

Disable the Copy/Move button until a destination source is chosen in that
case (no destination yet and no source in the open request, outside shares),
and use the existing "Choose a source" text as its tooltip. Normal listings
and shares are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 11:05:37 +01:00
Matt Van Horn d716fc15ec test: share the nested quota delete fixture between API and WebDAV tests
TestResourceDeleteNestedQuota and TestWebDAVDeleteNestedQuota seeded the
same files, quotas and usage deltas. Move that setup into
seedNestedQuotaFixture and call it from both.
2026-10-30 00:28:00 -07:00
Graham Steffaniak 56e16608e4 fix(frontend): keep video loading spinner off when the fetch stalls mid-playback (#3289) 2026-10-29 19:19:47 -05:00
Graham Steffaniak c471fc135b fix(frontend): use native video fullscreen on iPhone (#3288) 2026-10-29 19:18:33 -05:00
Graham Steffaniak 5112cf87b1 fix(analytics): report freebsd instead of "other" in system.os (#3284) 2026-10-29 19:17:23 -05:00
Graham Steffaniak 3d7bac47ee fix(stream): play single-file share media on Windows (#3271) 2026-10-29 19:10:22 -05:00
vulragrag-star 2a38d2fa1e fix(users): enforce lockPassword on self-service password changes (#3283) 2026-10-29 19:01:34 -05:00
Matt Van Horn 82e110c08d test: cover scoped users in the API nested quota delete test
The API path is scope-relative while quota paths and the index are
source-relative. Run the test for a user scoped to /scoped as well as /,
which fails if the delete looks up the unscoped path.
2026-10-29 08:14:00 -07:00
Matt Van Horn f4c96078a1 Merge remote-tracking branch 'upstream/main' into fix/3163-release-nested-folder-quotas-on-delete
# Conflicts:
#	backend/internal/web/resource.go
#	backend/internal/web/webdav.go
#	backend/internal/web/webdav_test.go
2026-10-29 08:14:00 -07:00
975adedabb Address directory indexing bugs (#3292)
Co-authored-by: Lucas Siqueira <lucas.morais.siqueira@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:29:42 -05:00
Graham Steffaniak 14ec0eba0d Fix indexing regressions (#3260) 2026-10-27 19:41:12 -05:00
Lucas de Morais SiqueiraandGraham Steffaniak 307d5f6591 fix(indexing): skip unreadable directory entries instead of failing the listing (#3184)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-27 19:25:01 -05:00
Get The Fork Off ff9df43356 fix(frontend): keep video loading spinner off when the fetch stalls mid-playback 2026-10-27 20:50:30 -03:00
Get The Fork Off 802e91aa05 fix(frontend): use native video fullscreen on iPhone 2026-10-27 20:48:46 -03:00
NefelibateLGandZCode Scout 28ef44cdec fix(analytics): report freebsd instead of "other" in system.os
The analytics snapshot classified every GOOS outside {linux, windows,
darwin} as "other", so the shipped freebsd-amd64 release binary
reported "os": "other" in the analytics payload even though
backend/.goreleaser.yaml builds freebsd as a first-class target
(#3244).

Add freebsd to the mapSystemOS whitelist, matching the release build
matrix, and add a table-driven test covering all four shipped GOOS
values plus the unsupported and empty fallbacks.

Fixes #3244

Co-authored-by: ZCode Scout <noreply@zcode.local>
Signed-off-by: NefelibateLG <3453510037@qq.com>
2026-10-27 09:16:06 +08:00
sarankumar1325 0ee0f35301 fix(stream): play single-file share media on Windows
shareRelativeDisplayName ran filepath.Base on slash-separated paths. On
Windows filepath.Base("/") returns "\", which passed the "/" check, so a
single-file share streamed with file=/ got "\" as its name instead of the
shared file's name. IsMediaStreamFile then rejected it and the stream
endpoint answered 403 "stream endpoint supports audio and video only".

Use path.Base, since both the share-relative path and the share path are
slash-separated.

Fixes #3270
2026-10-25 11:30:00 +05:30
Graham Steffaniak cc99aa542e updated with symlink extraction change (#3265) 2026-10-23 15:36:09 -05:00
8514da4a9a fix(auth): API token permission handling for customized tokens (#3255)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-23 14:48:18 -05:00
Graham SteffaniakandDevin 4625b89528 fix webp preview support (#3259)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 15:34:57 -05:00
Jay Hemnani b2a07196b7 fix(archive): extract tar hard links instead of dropping them (#3234) 2026-10-22 15:26:16 -05:00
Graham Steffaniak 0a8698ee4d updated changelog 2026-10-22 15:24:08 -05:00
ec8ad36c95 Feature/html preview navigation and media (#3131)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 15:13:31 -05:00
vulragrag-star 2c0c1b4bb2 fix(frontend): show the share banner to public visitors (#3250) 2026-10-22 13:38:49 -05:00
Thorsten Liborius 6fa71bd13d fix(frontend): show correct relative expiration time for sharesFix/share expiry relative time (#3247) 2026-10-21 18:47:44 -05:00
Graham Steffaniakandcoderabbitai[bot] 06aa16e7d7 fix raw embedded image support - rtf rw2 raw rwl (#3243)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-20 17:43:38 -05:00
Matt Van Horn bb45ebcce1 fix: avoid shadowing err in nested quota delete tests 2026-10-20 10:41:27 -07:00
Matt Van Horn 26665efc13 fix: release nested folder quotas when deleting their ancestor
Adds shared preparation and application helpers for API and WebDAV
deletion accounting. Before deletion removes index metadata, each nested
accounted quota is measured independently, while covering, scope, and
share quotas use the deleted item's size. Adjustments are applied only
after deletion succeeds, and unknown sizes are omitted. API deletion
also derives the source index path from the item's real path.

Fixes #3163
2026-10-20 04:39:51 -07:00
110 changed files with 4948 additions and 313 deletions

No files matched your search

+31
View File
@@ -2,6 +2,37 @@
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.1.4
**Security**:
- [Low] `GET /api/settings` and `GET /api/settings?property=auth` no longer return cleartext secrets (`auth.key`, `totpSecret`, `adminPassword`, OIDC `clientSecret`, LDAP bind password, recaptcha secret, OnlyOffice secret); secret fields are replaced with `**hidden**` in the response, and the YAML export now redacts the recaptcha and LDAP secrets whose comments lacked the `secret:` marker (GHSA-892f-9h5p-mfc8) reporter @paulmossman
**New Features**:
- feat(search): sidebar File actions and close button in advanced search (#3303) (#2479) (#3303)
**Bug Fixes**:
- Deleting a directory now releases folder quotas rooted inside it instead of leaving them charged for files that no longer exist; applies to API and WebDAV deletes (#3163) (#3235)
- Bulk and public share deletes now release accounted quota usage; nested folder quotas reset when an ancestor is removed (#3163) (#3305)
- Directory listings skip entries that return permission denied or not-found on per-entry stat instead of failing the whole folder (e.g. unreadable NTFS junctions on CIFS); skipped entries are omitted from the index like `ls -l` (#3184)
- Preview generation for webp broken (#3254) (#3259)
- Share banner never shows to public visitors, returns bannerUrl but not banner (#3246) (#3250)
- fix html/markdown relative file preview support (#3131) (#3129) (#2355)
- fix(archive): extract tar hard links instead of dropping them (#3234)
- fix(auth): apply stored permission caps for customized API tokens with no global flags enabled (#3255)
- Archive extraction writes regular and hard-link entries at the entry path instead of following an existing symlink at that path (tar and zip) (#3265)
- fix(move-copy): require choosing a destination source in advanced search when none can be inferred, instead of silently copying to the source root (#3276)
- fix(stream): single-file shares can now stream their own media on Windows by using slash-based path handling (#3270) (#3271)
- fix(users): `lockPassword` is now enforced on self-service password changes; non-admins get 403 while admin resets still work (#3279) (#3283)
- fix(analytics): the FreeBSD release binary now reports `freebsd` in `system.os` instead of `other` (#3244) (#3284)
- fix(frontend): share expiration now reads backend Unix-seconds timestamps correctly instead of showing dates decades off (#3247)
- fix(frontend): video on iPhone now uses the native fullscreen player instead of the in-page fallback (#3113) (#3288)
- fix(frontend): the video loading spinner no longer stays on for the rest of playback when the fetch stalls but the buffer can continue (#3287) (#3289)
- fix(frontend): the share banner is now shown to public visitors, using `bannerUrl` instead of the never-set `banner` field (#3246) (#3250)
- fix(preview): camera RAW files (Panasonic `.rw2`/`.raw`/`.rwl`, Olympus `.orf`, `.cr2`/`.dng`) extract embedded previews from TIFF strips/SubIFDs and no longer return 415 when EXIF decoding fails (#3219) (#3243)
- fix(indexing): restore lazy listing persistence with atomic folder-size deltas and ancestor propagation, and force a full scan for scanners that missed their slot while the process was down (#3260)
- fix(indexing): directory listings skip entries that cannot be accessed or have vanished instead of failing the whole listing; remaining errors still fail and fully-vanished nonempty directories return an error (#3292)
- fix(frontend): redirects from `/files` stay inside the files area so a source named "Files" no longer leaves an empty view after login (#3167) (#3304)
## v2.1.3
**Security**:
+9 -5
View File
@@ -3,7 +3,6 @@ package activity
import (
"net"
"net/http"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
@@ -11,14 +10,19 @@ import (
func remoteIP(r *http.Request) string {
cfg := &settings.Config
// Forwarded client headers are only honored when the socket peer is a
// configured trusted proxy; otherwise a direct client could spoof its IP.
trustedPeer := cfg.Http.TrustProxyHeaders && settings.IsTrustedProxyPeer(r)
xff := r.Header.Get("X-Forwarded-For")
if cfg.Http.TrustProxyHeaders && xff != "" {
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
if trustedPeer && xff != "" {
if ip := settings.TrustedProxyClientIP(xff); ip != "" {
return ip
}
}
xri := r.Header.Get("X-Real-IP")
if cfg.Http.TrustProxyHeaders && xri != "" {
if trustedPeer && xri != "" {
return xri
}
@@ -335,6 +335,15 @@ func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (*
if info.Type == "directory" && opts.HideFileExt != "" {
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
}
if info.Type == "directory" && opts.Expand {
// Expanded listings without extended attrs skip persistDirectoryListing
// (their placeholder preview values must not overwrite index rows), so
// refresh the index lazily — otherwise folder sizes here never update.
// Capture indexPath now: the deferred finalizeResponse strips the user
// scope from response.Path before returning.
// Coalesced per path so repeated listings do not run parallel refreshes.
idx.RefreshDirectoryAsync(idx.MakeIndexPath(response.Path, true).String())
}
return response, nil
}
// Process directory metadata if requested
+1 -1
View File
@@ -301,7 +301,7 @@ func detectRuntime() string {
func mapSystemOS(goos string) string {
switch goos {
case "linux", "windows", "darwin":
case "linux", "windows", "darwin", "freebsd":
return goos
default:
return "other"
@@ -0,0 +1,25 @@
package analytics
import "testing"
func TestMapSystemOS(t *testing.T) {
tests := []struct {
name string
goos string
want string
}{
{name: "linux", goos: "linux", want: "linux"},
{name: "windows", goos: "windows", want: "windows"},
{name: "darwin", goos: "darwin", want: "darwin"},
{name: "freebsd release build", goos: "freebsd", want: "freebsd"},
{name: "unsupported goos", goos: "plan9", want: "other"},
{name: "empty", goos: "", want: "other"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mapSystemOS(tt.goos); got != tt.want {
t.Errorf("mapSystemOS(%q) = %q, want %q", tt.goos, got, tt.want)
}
})
}
}
+1
View File
@@ -57,6 +57,7 @@ func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
},
},
}
claim.Minimal = &minimal
if !minimal {
claim.Permissions = users.SanitizeTokenPermissions(perms)
}
@@ -24,7 +24,11 @@ func HasAnyGlobalPermission(p Permissions) bool {
}
// IsMinimalApiToken reports whether the token inherits the owner's globals at auth time
// rather than embedding its own global permission caps in the JWT.
// rather than applying stored global permission caps.
func IsMinimalApiToken(t AuthToken) bool {
if t.Minimal != nil {
return *t.Minimal
}
// Legacy tokens lack minimal; empty stored global caps implied minimal (WebDAV-style).
return !HasAnyGlobalPermission(t.Permissions)
}
@@ -45,6 +45,14 @@ func TestIsMinimalApiToken(t *testing.T) {
t.Fatal("custom token with global caps should not be minimal")
}
if !IsMinimalApiToken(AuthToken{Permissions: Permissions{Modify: true}}) {
t.Fatal("token without global caps should be minimal")
t.Fatal("legacy token without global caps should be minimal")
}
minimal := true
if !IsMinimalApiToken(AuthToken{Minimal: &minimal}) {
t.Fatal("explicit minimal token should be minimal")
}
customized := false
if IsMinimalApiToken(AuthToken{Minimal: &customized}) {
t.Fatal("explicit customized token with empty caps should not be minimal")
}
}
+2
View File
@@ -105,6 +105,8 @@ type AuthToken struct {
BelongsTo uint64 `json:"belongsTo,omitempty"` // legacy stored metadata only, never trusted for auth; identity is always the hashed_tokens lookup
IssuedAt int64 `json:"issuedAt,omitempty"`
ExpiresAt int64 `json:"expiresAt,omitempty"`
// Minimal distinguishes owner-inheriting tokens from customized caps. Nil means legacy (infer from stored permissions).
Minimal *bool `json:"minimal,omitempty"`
Permissions Permissions `json:"Permissions,omitempty"`
}
+2 -2
View File
@@ -13,8 +13,8 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
@@ -209,7 +209,7 @@ func generateMaskableIcon(sourceData []byte, icon iconSize) error {
// Android adaptive-icon content ratio (72/108 ≈ 2/3) keeps square art inside every mask shape
inner := icon.size * 2 / 3
art := imaging.Fit(img, inner, inner, imaging.Lanczos)
canvas := imaging.PasteCenter(imaging.New(icon.size, icon.size, maskableBackground(img)), art)
canvas := imaging.OverlayCenter(imaging.New(icon.size, icon.size, maskableBackground(img)), art, 1)
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
outFile, err := os.Create(outputPath)
+81
View File
@@ -1,13 +1,94 @@
package icons
import (
"bytes"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/kovidgoyal/imaging"
)
func TestGenerateMaskableIconKeepsCanvasOpaque(t *testing.T) {
originalCacheDir := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = t.TempDir()
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCacheDir })
if err := os.MkdirAll(settings.PWAIconsCacheDir(), 0o755); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
background color.Color
source func(*image.RGBA)
}{
{
name: "transparent padding",
background: color.White,
source: func(img *image.RGBA) {
img.Set(5, 5, color.RGBA{R: 20, G: 40, B: 200, A: 255})
},
},
{
name: "opaque edge",
background: color.NRGBA{R: 200, G: 100, B: 50, A: 255},
source: func(img *image.RGBA) {
for x := 0; x < 12; x++ {
img.Set(x, 0, color.RGBA{R: 200, G: 100, B: 50, A: 255})
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
source := image.NewRGBA(image.Rect(0, 0, 12, 12))
tt.source(source)
var sourceData bytes.Buffer
if err := png.Encode(&sourceData, source); err != nil {
t.Fatal(err)
}
const size = 96
icon := iconSize{size: size, name: "maskable.png", maskable: true}
if err := generateMaskableIcon(sourceData.Bytes(), icon); err != nil {
t.Fatal(err)
}
output, err := imaging.Open(filepath.Join(settings.PWAIconsCacheDir(), icon.name))
if err != nil {
t.Fatal(err)
}
if output.Bounds().Dx() != size || output.Bounds().Dy() != size {
t.Fatalf("got bounds %v, want %dx%d", output.Bounds(), size, size)
}
want, ok := color.NRGBAModel.Convert(tt.background).(color.NRGBA)
if !ok {
t.Fatalf("background color %#v is not NRGBA", tt.background)
}
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
outsideArt := x < 16 || x >= 80 || y < 16 || y >= 80
got, ok := color.NRGBAModel.Convert(output.At(x, y)).(color.NRGBA)
if !ok {
t.Fatalf("pixel (%d,%d) is not NRGBA", x, y)
}
if got.A != 255 {
t.Fatalf("pixel (%d,%d) has alpha %d, want opaque", x, y, got.A)
}
if outsideArt && got != want {
t.Fatalf("pixel (%d,%d) got %#v, want background %#v", x, y, got, want)
}
}
}
})
}
}
func TestMaskableBackgroundOpaqueCorner(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
img.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
+6 -5
View File
@@ -78,12 +78,10 @@ func extractTIFFEmbeddedPreview(f *os.File) ([]byte, error) {
return nil, err
}
ex, err := extimagemeta.Decode(f)
if err != nil {
return nil, err
var candidates []previewCandidate
if ex, err := extimagemeta.Decode(f); err == nil {
candidates = previewCandidates(ex)
}
candidates := previewCandidates(ex)
if scanCandidates, scanErr := collectTIFFPreviewCandidates(f); scanErr == nil {
candidates = append(candidates, scanCandidates...)
}
@@ -115,6 +113,9 @@ func previewCandidates(ex exif.Exif) []previewCandidate {
out = append(out, previewCandidate{offset: ifd.ImageOffset, length: ifd.ImageLength})
}
}
for _, sub := range ex.SubIFDs {
appendIFD(sub)
}
appendIFD(ex.IFD1)
appendIFD(ex.IFD2)
+8 -7
View File
@@ -47,15 +47,16 @@ func GetOrientation(ctx context.Context, path string) string {
default:
ex, err = extimagemeta.Decode(f)
}
if err != nil {
return ""
}
if ctx.Err() != nil {
return ""
}
if ex.IFD0.Orientation == 0 {
return ""
if err == nil && ex.IFD0.Orientation != 0 {
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(ex.IFD0.Orientation))
}
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(ex.IFD0.Orientation))
if !isHEICExtension(ext) {
if orient := extractTIFFOrientation(f); orient != 0 {
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(orient))
}
}
return ""
}
+98 -11
View File
@@ -9,11 +9,18 @@ import (
)
const (
tiffTagStripOffsets = 0x0111
tiffTagOrientation = 0x0112
tiffTagStripByteCounts = 0x0117
tiffTagSubIFDs = 0x014a
tiffTagJpgFromRaw = 0x002e
tiffTagJPEGInterchange = 0x0201
tiffTagJPEGInterchangeLen = 0x0202
tiffIFDScanLimit = 4 * 1024 * 1024
tiffMagicStandard = 42
tiffMagicPanasonicRW2 = 0x0055
tiffMagicOlympusORF1 = 0x4f52
tiffMagicOlympusORF2 = 0x5352
tiffTypeByte = 1
tiffTypeASCII = 2
tiffTypeShort = 3
@@ -36,30 +43,47 @@ type tiffTag struct {
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) {
// readTIFFScanWindow reads the bounded IFD scan window (up to tiffIFDScanLimit bytes) of f.
// It validates the 8-byte TIFF header first, so non-TIFF files such as JPEGs are rejected
// without allocating or reading the full window. ok is false for non-TIFF or too-short files.
func readTIFFScanWindow(f *os.File) (header []byte, order binary.ByteOrder, ifd0 uint32, ok bool, err error) {
info, err := f.Stat()
if err != nil {
return nil, err
return nil, nil, 0, false, err
}
scanSize := info.Size()
if scanSize > tiffIFDScanLimit {
scanSize = tiffIFDScanLimit
}
if scanSize < 8 {
return nil, nil
return nil, nil, 0, false, nil
}
header := make([]byte, scanSize)
var magic [8]byte
if _, err = f.ReadAt(magic[:], 0); err != nil {
if err == io.EOF {
return nil, nil, 0, false, nil
}
return nil, nil, 0, false, err
}
if _, _, ok = tiffHeaderIFD0(magic[:]); !ok {
return nil, nil, 0, false, nil
}
header = make([]byte, scanSize)
n, err := f.ReadAt(header, 0)
if err != nil && err != io.EOF {
return nil, err
return nil, nil, 0, false, err
}
header = header[:n]
order, ifd0, ok = tiffHeaderIFD0(header)
return header, order, ifd0, ok, nil
}
order, ifd0, ok := tiffHeaderIFD0(header)
if !ok {
return nil, nil
// 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) {
header, order, ifd0, ok, err := readTIFFScanWindow(f)
if err != nil || !ok {
return nil, err
}
seen := make(map[uint32]struct{})
@@ -97,6 +121,7 @@ func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
for _, ifd := range ifds {
out = appendPreviewCandidates(out, jpgFromRawCandidates(ifd))
out = appendPreviewCandidates(out, jpegInterchangeCandidates(ifd))
out = appendPreviewCandidates(out, stripPreviewCandidates(ifd, order))
if len(out) >= maxTIFFPreviewCandidates {
break
}
@@ -104,6 +129,24 @@ func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
return out, nil
}
// extractTIFFOrientation reads standard TIFF IFD0 Orientation (0x0112) as a fallback
// when full EXIF decoding fails.
func extractTIFFOrientation(f *os.File) uint16 {
header, order, ifd0, ok, err := readTIFFScanWindow(f)
if err != nil || !ok || ifd0 == 0 {
return 0
}
ifd, _, _, err := parseTIFFIFD(header, order, ifd0)
if err != nil {
return 0
}
tag, ok := ifd.tags[tiffTagOrientation]
if !ok || tag.count == 0 {
return 0
}
return tiffTagFirstUint16(tag, order)
}
func appendPreviewCandidates(dst, src []previewCandidate) []previewCandidate {
remaining := maxTIFFPreviewCandidates - len(dst)
if remaining <= 0 {
@@ -144,6 +187,48 @@ func jpegInterchangeCandidates(ifd tiffIFD) []previewCandidate {
return []previewCandidate{{offset: offset, length: length}}
}
func stripPreviewCandidates(ifd tiffIFD, order binary.ByteOrder) []previewCandidate {
offTag, okOff := ifd.tags[tiffTagStripOffsets]
lenTag, okLen := ifd.tags[tiffTagStripByteCounts]
if !okOff || !okLen || offTag.count != 1 || lenTag.count != 1 {
return nil
}
offset := tiffTagScalarUint32(offTag, order)
length := tiffTagScalarUint32(lenTag, order)
if offset == 0 || length <= 4 || length > maxPreviewReadSize {
return nil
}
return []previewCandidate{{offset: offset, length: length}}
}
func tiffTagScalarUint32(t tiffTag, order binary.ByteOrder) uint32 {
switch t.typ {
case tiffTypeLong, tiffTypeIFD:
return t.valueOffset
case tiffTypeShort:
return uint32(tiffTagFirstUint16(t, order))
default:
return 0
}
}
func tiffTagFirstUint16(t tiffTag, order binary.ByteOrder) uint16 {
switch t.typ {
case tiffTypeShort:
if order == binary.BigEndian {
return uint16(t.valueOffset >> 16)
}
return uint16(t.valueOffset & 0xffff)
case tiffTypeLong:
if t.valueOffset > 0xffff {
return 0
}
return uint16(t.valueOffset)
default:
return 0
}
}
func tiffHeaderIFD0(data []byte) (binary.ByteOrder, uint32, bool) {
if len(data) < 8 {
return nil, 0, false
@@ -157,7 +242,9 @@ func tiffHeaderIFD0(data []byte) (binary.ByteOrder, uint32, bool) {
default:
return nil, 0, false
}
if order.Uint16(data[2:4]) != 42 {
switch order.Uint16(data[2:4]) {
case tiffMagicStandard, tiffMagicPanasonicRW2, tiffMagicOlympusORF1, tiffMagicOlympusORF2:
default:
return nil, 0, false
}
return order, order.Uint32(data[4:8]), true
+154 -3
View File
@@ -62,7 +62,7 @@ func TestCollectTIFFPreviewCandidatesIgnoresMalformedOffsets(t *testing.T) {
func TestCollectTIFFPreviewCandidatesIgnoresMalformedSubIFDOffset(t *testing.T) {
data := buildSyntheticTIFF(tiffFixture{
thumbnailJPEG: smallJPEG,
subIFDJPEG: largeJPEG,
subIFDJPEG: largeJPEG,
badJpgFromRaw: true,
})
f := openBytesFile(t, data)
@@ -114,6 +114,138 @@ func TestTiffTagUint32sSupportsIFDType(t *testing.T) {
}
}
func TestExtractEmbeddedPreviewFallbackWhenDecodeFails(t *testing.T) {
// Synthetic ARW where SubIFD offset is placed after IFD2, and a SubIFD tag
// points past EOF so extimagemeta.Decode fails with EOF, while IFD2 holds a
// valid JPEGInterchangeFormat preview.
order := binary.LittleEndian
buf := make([]byte, 32*1024)
copy(buf[0:2], []byte("II"))
order.PutUint16(buf[2:4], tiffMagicStandard)
ifd0Off := uint32(8)
order.PutUint32(buf[4:8], ifd0Off)
thumbOff := uint32(4096)
copy(buf[thumbOff:], smallJPEG)
largeOff := uint32(8192)
copy(buf[largeOff:], largeJPEG)
ifd1Off := uint32(256)
ifd2Off := uint32(512)
subIFDOff := uint32(768)
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)
}
writeIFD(ifd0Off, []tiffEntry{
{tag: tiffTagOrientation, typ: tiffTypeShort, count: 1, value: 6},
{tag: tiffTagSubIFDs, typ: tiffTypeLong, count: 1, value: subIFDOff},
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(smallJPEG))},
}, ifd1Off)
writeIFD(ifd1Off, []tiffEntry{
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(smallJPEG))},
}, ifd2Off)
writeIFD(ifd2Off, []tiffEntry{
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: largeOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(largeJPEG))},
}, 0)
// SubIFD has a StripOffsets tag with count=2 pointing beyond file end so Decode fails with EOF.
writeIFD(subIFDOff, []tiffEntry{
{tag: tiffTagStripOffsets, typ: tiffTypeLong, count: 2, value: uint32(len(buf) + 4096)},
}, 0)
path := filepath.Join(t.TempDir(), "sample.arw")
if err := os.WriteFile(path, buf[:int(largeOff)+len(largeJPEG)], 0o600); err != nil {
t.Fatal(err)
}
preview, err := ExtractEmbeddedPreview(t.Context(), path)
if err != nil {
t.Fatalf("ExtractEmbeddedPreview() err = %v", err)
}
if len(preview) != len(largeJPEG) {
t.Fatalf("ExtractEmbeddedPreview() len = %d, want %d", len(preview), len(largeJPEG))
}
if got := GetOrientation(t.Context(), path); got != "Rotate 90 CW" {
t.Fatalf("GetOrientation() = %q, want %q", got, "Rotate 90 CW")
}
}
func TestExtractEmbeddedPreviewSupportsRW2AndStripPreview(t *testing.T) {
order := binary.LittleEndian
buf := make([]byte, 16*1024)
copy(buf[0:2], []byte("II"))
order.PutUint16(buf[2:4], tiffMagicPanasonicRW2)
ifd0Off := uint32(8)
order.PutUint32(buf[4:8], ifd0Off)
largeOff := uint32(4096)
copy(buf[largeOff:], largeJPEG)
entries := []tiffEntry{
{tag: tiffTagStripOffsets, typ: tiffTypeLong, count: 1, value: largeOff},
{tag: tiffTagStripByteCounts, typ: tiffTypeLong, count: 1, value: uint32(len(largeJPEG))},
}
order.PutUint16(buf[ifd0Off:ifd0Off+2], uint16(len(entries)))
for i, entry := range entries {
pos := int(ifd0Off) + 2 + 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)
}
path := filepath.Join(t.TempDir(), "sample.rw2")
if err := os.WriteFile(path, buf[:int(largeOff)+len(largeJPEG)], 0o600); err != nil {
t.Fatal(err)
}
preview, err := ExtractEmbeddedPreview(t.Context(), path)
if err != nil {
t.Fatalf("ExtractEmbeddedPreview() err = %v", err)
}
if len(preview) != len(largeJPEG) {
t.Fatalf("ExtractEmbeddedPreview() len = %d, want %d", len(preview), len(largeJPEG))
}
}
func TestExtractEmbeddedPreviewDSC02672(t *testing.T) {
path := "/Users/steffag/Downloads/DSC02672-first-4MB.ARW"
if _, err := os.Stat(path); err != nil {
t.Skip(path)
}
got, err := ExtractEmbeddedPreview(t.Context(), path)
if err != nil {
t.Fatal(err)
}
if !IsJPEG(got) {
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
}
if len(got) != 2954147 {
t.Fatalf("preview size = %d, want 2954147", len(got))
}
if orient := GetOrientation(t.Context(), path); orient != "Horizontal (normal)" {
t.Fatalf("GetOrientation() = %q, want Horizontal (normal)", orient)
}
}
type tiffFixture struct {
thumbnailJPEG []byte
subIFDJPEG []byte
@@ -195,8 +327,8 @@ func buildSyntheticTIFF(fix tiffFixture) []byte {
}
type tiffEntry struct {
tag, typ uint16
count, value uint32
tag, typ uint16
count, value uint32
}
func buildChainedTIFF(chainLen int) []byte {
@@ -218,3 +350,22 @@ func buildChainedTIFF(chainLen int) []byte {
}
return buf
}
// Non-TIFF files (e.g. a JPEG reaching the orientation fallback) must be rejected from the
// 8-byte header, not after reading the whole scan window.
func TestReadTIFFScanWindowRejectsNonTIFFFromHeader(t *testing.T) {
jpeg := append([]byte{0xff, 0xd8, 0xff, 0xe0}, make([]byte, 1<<20)...)
header, _, _, ok, err := readTIFFScanWindow(openBytesFile(t, jpeg))
if err != nil || ok || header != nil {
t.Fatalf("got header=%d bytes ok=%v err=%v, want nil/false/nil", len(header), ok, err)
}
if got := extractTIFFOrientation(openBytesFile(t, jpeg)); got != 0 {
t.Fatalf("orientation = %d, want 0", got)
}
tiff := buildSyntheticTIFF(tiffFixture{thumbnailJPEG: smallJPEG})
header, _, ifd0, ok, err := readTIFFScanWindow(openBytesFile(t, tiff))
if err != nil || !ok || ifd0 == 0 || len(header) != len(tiff) {
t.Fatalf("tiff: header=%d ok=%v ifd0=%d err=%v", len(header), ok, ifd0, err)
}
}
+15 -1
View File
@@ -76,6 +76,18 @@ func (x Format) toImaging() imaging.Format {
}
}
// encodable reports whether imaging can encode this format.
// imaging only encodes JPEG, PNG, GIF, TIFF and BMP; webp and the netpbm
// formats are decode-only, so previews for them must be written as JPEG.
func (x Format) encodable() bool {
switch x {
case FormatJpeg, FormatPng, FormatGif, FormatTiff, FormatBmp:
return true
default:
return false
}
}
/*
ENUM(
high
@@ -203,7 +215,9 @@ func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
var wrappedReader = in
var err error
if opts.Format == FormatHeic {
// HEIC has no encoder and webp/netpbm are decode-only in imaging; fall back
// to JPEG so preview generation does not fail with ErrUnsupportedFormat.
if !opts.Format.encodable() {
opts.Format = FormatJpeg
}
// Ensure JPEG quality is set based on quality setting
+8
View File
@@ -161,6 +161,14 @@ func TestService_Resize(t *testing.T) {
},
matcher: sizeMatcher(100, 100),
},
"resize webp falls back to jpeg": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Format: FormatWebp},
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/gray-sample.webp")
},
matcher: formatMatcher(FormatJpeg),
},
"resize with high quality": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityHigh},
source: func(t *testing.T) afero.File {
-9
View File
@@ -272,15 +272,6 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended
case previewTypeImage:
ext := strings.ToLower(filepath.Ext(file.Name))
if iteminfo.IsRawImage(ext) {
if s.ffmpegService != nil {
previewBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err == nil && len(previewBytes) >= minPreviewSize {
return previewBytes, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
}
return nil, ErrUnsupportedFormat
}
return s.generateImagePreview(ctx, file, previewSize)
+96 -17
View File
@@ -536,8 +536,8 @@ func ApplicableFolderQuotas(sourcePath, destIndexPath string, principalUserID ui
}
type reserveApplyPlan struct {
chk QuotaReserveCheck
existingIdx int
chk QuotaReserveCheck
existingIdx int
counterDelta int64
}
@@ -715,6 +715,98 @@ func ForceCommitSessionQuota(sessionID string, deltaBytes int64) error {
return nil
}
// DeleteAdjustments holds accounted quota updates to apply after a successful delete.
type DeleteAdjustments struct {
Deltas map[string]int64
Resets []string
}
// PrepareAccountedDelete captures quota adjustments while deleted paths are still indexed.
// Folder quotas rooted inside a deleted directory are reset; covering quotas use itemBytes.
func PrepareAccountedDelete(principal *users.User, sourceName, sourcePath, indexPath, shareHash string, isDir bool, itemBytes int64) DeleteAdjustments {
adj := DeleteAdjustments{Deltas: make(map[string]int64)}
if principal == nil {
return adj
}
indexPath = normalizeQuotaPath(indexPath)
for _, fq := range ListFolderQuotasForSource(sourcePath) {
if fq.LimitBytes <= 0 || (fq.UserID != 0 && fq.UserID != principal.ID) {
continue
}
if isDir && indexPathCovers(indexPath, fq.Path) {
if quota.EffectiveMeter(configuredFolderMeter(fq), sourceName, fq.Path) != quota.MeterAccounted {
continue
}
adj.Resets = append(adj.Resets, fq.ID)
continue
}
if !indexPathCovers(fq.Path, indexPath) {
continue
}
if quota.EffectiveMeter(configuredFolderMeter(fq), sourceName, fq.Path) != quota.MeterAccounted {
continue
}
if itemBytes > 0 {
adj.Deltas[fq.ID] = -itemBytes
}
}
if itemBytes > 0 {
for _, id := range accountedScopeQuotaIDs(principal, sourceName, sourcePath) {
adj.Deltas[id] = -itemBytes
}
if shareHash != "" {
adj.Deltas[quota.ShareQuotaID(shareHash)] = -itemBytes
}
}
return adj
}
// ApplyAccountedDelete applies prepared adjustments only after a successful deletion.
func ApplyAccountedDelete(adj DeleteAdjustments) error {
quotasMux.Lock()
defer quotasMux.Unlock()
defer signalQuotaFlush()
for id, delta := range adj.Deltas {
if err := applyCounterDeltaLocked(id, delta); err != nil {
return err
}
}
for _, id := range adj.Resets {
if err := resetAccountedUsageLocked(id); err != nil {
return err
}
}
return nil
}
func resetAccountedUsageLocked(quotaID string) error {
if err := ensureQuotaCounterMemLocked(quotaID); err != nil {
return err
}
c := quotaCounters[quotaID]
c.UsedBytes = 0
c.Dirty = true
markQuotaCounterDirty(quotaID)
return nil
}
func accountedScopeQuotaIDs(principal *users.User, sourceName, sourcePath string) []string {
var ids []string
for _, bs := range principal.BackendScopes {
if bs.Path != sourcePath || bs.Quota == nil || bs.Quota.LimitBytes <= 0 {
continue
}
configured := bs.Quota.Meter
if configured == "" {
configured = quota.MeterIndexScope
}
if quota.EffectiveMeter(configured, sourceName, scopeIndexPath(principal, sourceName)) == quota.MeterAccounted {
ids = append(ids, bs.Quota.ID)
}
}
return ids
}
// ApplyAccountedUsageDelta adjusts accounted and share quota counters for a path (negative delta frees space).
func ApplyAccountedUsageDelta(principal *users.User, sourceName, sourcePath, indexPath, shareHash string, delta int64) error {
if delta == 0 || principal == nil {
@@ -735,21 +827,8 @@ func ApplyAccountedUsageDelta(principal *users.User, sourceName, sourcePath, ind
}
}
for _, bs := range principal.BackendScopes {
if bs.Path != sourcePath || bs.Quota == nil || bs.Quota.LimitBytes <= 0 {
continue
}
sq := bs.Quota
configured := sq.Meter
if configured == "" {
configured = quota.MeterIndexScope
}
scopePath := scopeIndexPath(principal, sourceName)
effective := quota.EffectiveMeter(configured, sourceName, scopePath)
if effective != quota.MeterAccounted {
continue
}
if err := applyCounterDeltaLocked(sq.ID, delta); err != nil {
for _, id := range accountedScopeQuotaIDs(principal, sourceName, sourcePath) {
if err := applyCounterDeltaLocked(id, delta); err != nil {
return err
}
}
@@ -0,0 +1,135 @@
package state
import (
"reflect"
"slices"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
)
func TestPrepareAccountedDelete(t *testing.T) {
oldFolders, oldSources, oldCounters := folderQuotasByID, folderQuotasBySource, quotaCounters
folderQuotasByID = make(map[string]*quota.FolderQuota)
folderQuotasBySource = make(map[string][]string)
quotaCounters = make(map[string]*quotaCounterMem)
t.Cleanup(func() {
folderQuotasByID, folderQuotasBySource, quotaCounters = oldFolders, oldSources, oldCounters
indexing.ClearTestIndices()
})
indexing.SetTestIndex("delete", "/source")
idx := indexing.GetIndex("delete")
idx.SetFolderSize("/public/", 60)
idx.SetFolderSize("/public/nested/", 50)
idx.SetFolderSize("/public/nested/deeper/", 30)
principal := &users.User{ID: 1, BackendScopes: []users.BackendScope{{Path: "/source", Scope: "/", Quota: &users.ScopeQuota{ID: "scope", LimitBytes: 1000, Meter: quota.MeterAccounted}}}}
for _, q := range []quota.FolderQuota{
{ID: "cover", Source: "/source", Path: "/", Meter: quota.MeterAccounted},
{ID: "exact", Source: "/source", Path: "/public", Meter: quota.MeterAccounted},
{ID: "nested", Source: "/source", Path: "/public/nested", Meter: quota.MeterAccounted, UserID: 1},
{ID: "deeper", Source: "/source", Path: "/public/nested/deeper", Meter: quota.MeterAccounted},
{ID: "missing", Source: "/source", Path: "/public/missing", Meter: quota.MeterAccounted},
{ID: "sibling", Source: "/source", Path: "/sibling", Meter: quota.MeterAccounted},
{ID: "prefix", Source: "/source", Path: "/public-other", Meter: quota.MeterAccounted},
{ID: "other-user", Source: "/source", Path: "/public/nested", Meter: quota.MeterAccounted, UserID: 2},
{ID: "other-source", Source: "/other", Path: "/public/nested", Meter: quota.MeterAccounted},
{ID: "index", Source: "/source", Path: "/public/nested", Meter: quota.MeterIndexSize},
} {
q.LimitBytes = 1000
storeFolderQuota(q)
quotaCounters[q.ID] = &quotaCounterMem{UsedBytes: 100, ReservedBytes: 7}
}
shareID := quota.ShareQuotaID("share")
quotaCounters["scope"] = &quotaCounterMem{UsedBytes: 100, ReservedBytes: 7}
quotaCounters[shareID] = &quotaCounterMem{UsedBytes: 100, ReservedBytes: 7}
wantDeltas := map[string]int64{"cover": -60, "scope": -60, shareID: -60}
wantResets := []string{"exact", "nested", "deeper", "missing"}
got := PrepareAccountedDelete(principal, "delete", "/source", "/public", "share", true, 60)
if !reflect.DeepEqual(got.Deltas, wantDeltas) {
t.Fatalf("deltas = %v, want %v", got.Deltas, wantDeltas)
}
if !slicesEqual(got.Resets, wantResets) {
t.Fatalf("resets = %v, want %v", got.Resets, wantResets)
}
for id, delta := range wantDeltas {
quotaCounters[id].UsedBytes = -delta
}
quotaCounters["cover"].UsedBytes += 40
for _, id := range wantResets {
quotaCounters[id].UsedBytes = 100
}
if err := ApplyAccountedDelete(got); err != nil {
t.Fatal(err)
}
for id, c := range quotaCounters {
expected := int64(100)
if _, ok := wantDeltas[id]; ok {
expected = 0
}
if slices.Contains(wantResets, id) {
expected = 0
}
if id == "cover" {
expected = 40
}
if c.UsedBytes != expected || c.ReservedBytes != 7 {
t.Errorf("%s: used=%d reserved=%d, want %d/7", id, c.UsedBytes, c.ReservedBytes, expected)
}
}
for _, size := range []int64{0, -1} {
got = PrepareAccountedDelete(principal, "delete", "/source", "/public", "", true, size)
wantResetsUnknown := []string{"exact", "nested", "deeper", "missing"}
if len(got.Deltas) != 0 || !slicesEqual(got.Resets, wantResetsUnknown) {
t.Fatalf("unavailable/zero ancestor: deltas=%v resets=%v", got.Deltas, got.Resets)
}
}
got = PrepareAccountedDelete(principal, "delete", "/source", "/public/nested/file", "", false, 5)
if !reflect.DeepEqual(got, DeleteAdjustments{
Deltas: map[string]int64{"cover": -5, "exact": -5, "nested": -5, "scope": -5},
}) {
t.Fatalf("file adjustments: %v", got)
}
empty := PrepareAccountedDelete(nil, "delete", "/source", "/public", "share", true, 60)
if len(empty.Deltas) != 0 || len(empty.Resets) != 0 {
t.Fatalf("nil principal: %v", empty)
}
}
func TestPrepareAccountedDeleteResetsWithoutIndexedSizes(t *testing.T) {
oldFolders, oldSources, oldCounters := folderQuotasByID, folderQuotasBySource, quotaCounters
folderQuotasByID = make(map[string]*quota.FolderQuota)
folderQuotasBySource = make(map[string][]string)
quotaCounters = make(map[string]*quotaCounterMem)
t.Cleanup(func() {
folderQuotasByID, folderQuotasBySource, quotaCounters = oldFolders, oldSources, oldCounters
indexing.ClearTestIndices()
})
indexing.SetTestIndex("delete", "/source")
principal := &users.User{ID: 1}
nested := quota.FolderQuota{ID: "nested", Source: "/source", Path: "/public/nested", Meter: quota.MeterAccounted, LimitBytes: 1000}
storeFolderQuota(nested)
quotaCounters["nested"] = &quotaCounterMem{UsedBytes: 50, ReservedBytes: 3}
got := PrepareAccountedDelete(principal, "delete", "/source", "/public", "", true, 0)
if !slicesEqual(got.Resets, []string{"nested"}) {
t.Fatalf("resets = %v, want nested reset without index sizes", got.Resets)
}
if err := ApplyAccountedDelete(got); err != nil {
t.Fatal(err)
}
if used, reserved := GetQuotaCounterSnapshot("nested"); used != 0 || reserved != 3 {
t.Fatalf("nested used=%d reserved=%d, want 0/3", used, reserved)
}
}
func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
ac := slices.Clone(a)
bc := slices.Clone(b)
slices.Sort(ac)
slices.Sort(bc)
return slices.Equal(ac, bc)
}
+73 -1
View File
@@ -1198,6 +1198,26 @@ func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) er
return applyArchivedTimesAndPerm(destPath, mode, modTime)
}
// removeSymlinkDestination removes destPath when it is a symlink so a regular-file
// write creates the entry at destPath instead of following the link.
func removeSymlinkDestination(destPath string) error {
info, err := os.Lstat(destPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&fs.ModeSymlink == 0 {
return nil
}
if err := os.Remove(destPath); err != nil {
logger.Debugf("unarchive: remove symlink destination: %v", err)
return fmt.Errorf("failed to write file from archive entry")
}
return nil
}
// extractArchivedRegularFile writes one regular file from src (closed by this function) and reapplies mode and mtime.
func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.Time, src io.ReadCloser) error {
defer func() { _ = src.Close() }()
@@ -1206,6 +1226,9 @@ func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.
logger.Debugf("unarchive: mkdir parent: %v", err)
return fmt.Errorf("failed to create directory from archive entry")
}
if err := removeSymlinkDestination(destPath); err != nil {
return err
}
perm := mode.Perm()
if perm == 0 {
perm = fileutils.EffectiveFilePerm()
@@ -1250,6 +1273,47 @@ func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
}
// extractArchivedHardLink writes a copy of a regular file extracted earlier from the same
// archive, which is what a tar hard link entry stands for.
func extractArchivedHardLink(destRoot, destPath string, h *tar.Header, extracted map[string]os.FileInfo) error {
targetPath, err := safeExtractPath(destRoot, h.Linkname)
if err != nil {
return err
}
notExtracted := fmt.Errorf("hard link %q points to %q, which is not a file earlier in the archive", h.Name, h.Linkname)
written, ok := extracted[targetPath]
if !ok {
return notExtracted
}
src, err := os.Open(targetPath)
if err != nil {
logger.Debugf("unarchive: open hard link target: %v", err)
return fmt.Errorf("failed to write file from archive entry")
}
// A later entry may have replaced the target, e.g. with a symlink to another file.
info, err := src.Stat()
if err != nil || !info.Mode().IsRegular() || !os.SameFile(info, written) {
_ = src.Close()
return notExtracted
}
// The entry may name its own target, directly (GNU tar does this for a file archived
// twice). Writing it would truncate the file it copies from. Symlinks at destPath
// are replaced by extractArchivedRegularFile instead of being left in place.
if dest, err := os.Lstat(destPath); err == nil && dest.Mode().IsRegular() && os.SameFile(dest, info) {
return src.Close()
}
return extractArchivedRegularFile(destPath, h.FileInfo().Mode(), h.ModTime, src)
}
// recordExtractedFile remembers the regular file written at destPath for later hard links.
func recordExtractedFile(extracted map[string]os.FileInfo, destPath string) {
if info, err := os.Lstat(destPath); err == nil && info.Mode().IsRegular() {
extracted[destPath] = info
} else {
delete(extracted, destPath)
}
}
func extractZip(archivePath, destDir string) error {
return extractZipWithEncoding(archivePath, destDir, "", nil)
}
@@ -1396,6 +1460,8 @@ func extractTarGz(archivePath, destDir string, policy *extractAccessPolicy) erro
defer gz.Close()
tr := tar.NewReader(gz)
// Regular files written so far, for hard links to point at.
extracted := make(map[string]os.FileInfo)
for {
h, err := tr.Next()
if err == io.EOF {
@@ -1420,6 +1486,12 @@ func extractTarGz(archivePath, destDir string, policy *extractAccessPolicy) erro
if err = extractArchivedRegularFile(destPath, mode, h.ModTime, io.NopCloser(tr)); err != nil {
return err
}
recordExtractedFile(extracted, destPath)
case tar.TypeLink:
if err = extractArchivedHardLink(destDir, destPath, h, extracted); err != nil {
return err
}
recordExtractedFile(extracted, destPath)
case tar.TypeSymlink:
if err = extractArchivedSymlink(destDir, destPath, h.Linkname, h.ModTime); err != nil {
return err
@@ -1469,7 +1541,7 @@ func preflightTarGz(archivePath, destDir string, policy *extractAccessPolicy) er
if err = preflightExtractEntry(policy, h.Name, destPath, true, false); err != nil {
return err
}
case tar.TypeReg:
case tar.TypeReg, tar.TypeLink:
if err = preflightExtractEntry(policy, h.Name, destPath, false, false); err != nil {
return err
}
+214
View File
@@ -1,7 +1,9 @@
package web
import (
"archive/tar"
"archive/zip"
"io"
"os"
"path/filepath"
"strings"
@@ -343,3 +345,215 @@ func TestExtractZipFilenameEncoding(t *testing.T) {
})
}
}
func TestExtractTarGz_hardLinks(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "links.tar.gz")
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
if err := tw.WriteHeader(&tar.Header{Name: "proj/a.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
return err
}
if _, err := io.WriteString(tw, "same"); err != nil {
return err
}
return tw.WriteHeader(&tar.Header{Name: "proj/sub/b.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "proj/a.txt"})
})
destDir := t.TempDir()
if err := extractTarGz(archivePath, destDir, nil); err != nil {
t.Fatalf("extract: %v", err)
}
got, err := os.ReadFile(filepath.Join(destDir, "proj", "sub", "b.txt"))
if err != nil || string(got) != "same" {
t.Fatalf("hard link entry: got %q, %v", got, err)
}
missing := filepath.Join(t.TempDir(), "missing.tar.gz")
writeTestTarGz(t, missing, func(tw *tar.Writer) error {
return tw.WriteHeader(&tar.Header{Name: "b.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"})
})
if err := extractTarGz(missing, t.TempDir(), nil); err == nil {
t.Fatal("expected an error for a hard link to a file that is not in the archive")
}
// A symlink entry replaces the extracted file before the hard link names it.
replaced := filepath.Join(t.TempDir(), "replaced.tar.gz")
writeTestTarGz(t, replaced, func(tw *tar.Writer) error {
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
return err
}
if _, err := io.WriteString(tw, "mine"); err != nil {
return err
}
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "private/secret.txt"}); err != nil {
return err
}
return tw.WriteHeader(&tar.Header{Name: "b.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"})
})
replacedDest := t.TempDir()
if err := os.MkdirAll(filepath.Join(replacedDest, "private"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(replacedDest, "private", "secret.txt"), []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
if err := extractTarGz(replaced, replacedDest, nil); err == nil {
t.Fatal("expected an error for a hard link whose target was replaced by a symlink")
}
if got, err := os.ReadFile(filepath.Join(replacedDest, "b.txt")); err == nil {
t.Fatalf("hard link entry copied the symlink target: %q", got)
}
// A hard link names its own target, as GNU tar writes for a file archived twice,
// or names it through a symlink.
self := filepath.Join(t.TempDir(), "self.tar.gz")
writeTestTarGz(t, self, func(tw *tar.Writer) error {
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
return err
}
if _, err := io.WriteString(tw, "same"); err != nil {
return err
}
if err := tw.WriteHeader(&tar.Header{Name: "c.txt", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "a.txt"}); err != nil {
return err
}
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"}); err != nil {
return err
}
return tw.WriteHeader(&tar.Header{Name: "c.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"})
})
selfDest := t.TempDir()
if err := extractTarGz(self, selfDest, nil); err != nil {
t.Fatalf("extract: %v", err)
}
if got, err := os.ReadFile(filepath.Join(selfDest, "a.txt")); err != nil || string(got) != "same" {
t.Fatalf("hard link to itself: got %q, %v", got, err)
}
}
func TestExtractTarGz_symlinkDestination(t *testing.T) {
t.Run("pre-existing symlink at destination", func(t *testing.T) {
destDir := t.TempDir()
victim := filepath.Join(destDir, "victim.txt")
if err := os.WriteFile(victim, []byte("untouched"), 0o644); err != nil {
t.Fatal(err)
}
outPath := filepath.Join(destDir, "out.txt")
if err := os.Symlink("victim.txt", outPath); err != nil {
t.Fatal(err)
}
archivePath := filepath.Join(t.TempDir(), "out.tar.gz")
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
if err := tw.WriteHeader(&tar.Header{Name: "out.txt", Mode: 0o644, Size: 7, Typeflag: tar.TypeReg}); err != nil {
return err
}
_, err := io.WriteString(tw, "payload")
return err
})
if err := extractTarGz(archivePath, destDir, nil); err != nil {
t.Fatalf("extract: %v", err)
}
got, err := os.ReadFile(outPath)
if err != nil || string(got) != "payload" {
t.Fatalf("out.txt: got %q, %v", got, err)
}
victimData, err := os.ReadFile(victim)
if err != nil || string(victimData) != "untouched" {
t.Fatalf("victim.txt should be unchanged: %q, %v", victimData, err)
}
info, err := os.Lstat(outPath)
if err != nil || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("out.txt should be a regular file, mode=%v err=%v", info.Mode(), err)
}
})
t.Run("symlink entry then regular file at same path", func(t *testing.T) {
destDir := t.TempDir()
other := filepath.Join(destDir, "other.txt")
if err := os.WriteFile(other, []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
archivePath := filepath.Join(t.TempDir(), "link_then_reg.tar.gz")
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
if err := tw.WriteHeader(&tar.Header{Name: "link", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "other.txt"}); err != nil {
return err
}
if err := tw.WriteHeader(&tar.Header{Name: "link", Mode: 0o644, Size: 7, Typeflag: tar.TypeReg}); err != nil {
return err
}
_, err := io.WriteString(tw, "written")
return err
})
if err := extractTarGz(archivePath, destDir, nil); err != nil {
t.Fatalf("extract: %v", err)
}
linkPath := filepath.Join(destDir, "link")
got, err := os.ReadFile(linkPath)
if err != nil || string(got) != "written" {
t.Fatalf("link: got %q, %v", got, err)
}
otherData, err := os.ReadFile(other)
if err != nil || string(otherData) != "keep" {
t.Fatalf("other.txt should be unchanged: %q, %v", otherData, err)
}
})
t.Run("hard link onto symlink to source", func(t *testing.T) {
destDir := t.TempDir()
archivePath := filepath.Join(t.TempDir(), "hardlink_symlink_dest.tar.gz")
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
return err
}
if _, err := io.WriteString(tw, "same"); err != nil {
return err
}
if err := tw.WriteHeader(&tar.Header{Name: "b.txt", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "a.txt"}); err != nil {
return err
}
return tw.WriteHeader(&tar.Header{Name: "c.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"})
})
if err := extractTarGz(archivePath, destDir, nil); err != nil {
t.Fatalf("extract: %v", err)
}
cPath := filepath.Join(destDir, "c.txt")
got, err := os.ReadFile(cPath)
if err != nil || string(got) != "same" {
t.Fatalf("c.txt: got %q, %v", got, err)
}
info, err := os.Lstat(cPath)
if err != nil || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("c.txt should be a regular file")
}
})
}
func TestExtractZip_symlinkDestination(t *testing.T) {
destDir := t.TempDir()
victim := filepath.Join(destDir, "victim.txt")
if err := os.WriteFile(victim, []byte("untouched"), 0o644); err != nil {
t.Fatal(err)
}
outPath := filepath.Join(destDir, "out.txt")
if err := os.Symlink("victim.txt", outPath); err != nil {
t.Fatal(err)
}
zipPath := filepath.Join(t.TempDir(), "out.zip")
writeTestZip(t, zipPath, func(zw *zip.Writer) error {
w, err := zw.Create("out.txt")
if err != nil {
return err
}
_, err = w.Write([]byte("payload"))
return err
})
if err := extractZip(zipPath, destDir); err != nil {
t.Fatalf("extract: %v", err)
}
got, err := os.ReadFile(outPath)
if err != nil || string(got) != "payload" {
t.Fatalf("out.txt: got %q, %v", got, err)
}
victimData, err := os.ReadFile(victim)
if err != nil || string(victimData) != "untouched" {
t.Fatalf("victim.txt should be unchanged: %q, %v", victimData, err)
}
}
+1 -1
View File
@@ -594,7 +594,7 @@ func applyNamedApiTokenGlobalCaps(user *users.User, tokenName string) {
if !ok {
return
}
if !users.HasAnyGlobalPermission(stored.Permissions) {
if users.IsMinimalApiToken(stored) {
return
}
user.Permissions = users.IntersectGlobalPermissions(user.Permissions, stored.Permissions)
+18
View File
@@ -197,9 +197,11 @@ func TestApplyNamedApiTokenGlobalCaps(t *testing.T) {
t.Run("custom api token caps globals", func(t *testing.T) {
user := *owner
customized := false
user.Tokens = map[string]users.AuthToken{
"customized": {
Name: "customized",
Minimal: &customized,
Permissions: users.Permissions{
Admin: false,
Api: true,
@@ -216,4 +218,20 @@ func TestApplyNamedApiTokenGlobalCaps(t *testing.T) {
t.Fatalf("expected api and share, got %#v", user.Permissions)
}
})
t.Run("customized zero global caps", func(t *testing.T) {
user := *owner
customized := false
user.Tokens = map[string]users.AuthToken{
"locked-down": {
Name: "locked-down",
Minimal: &customized,
Permissions: users.Permissions{},
},
}
applyNamedApiTokenGlobalCaps(&user, "locked-down")
if user.Permissions.Admin || user.Permissions.Api || user.Permissions.Share {
t.Fatalf("customized empty caps should clear globals, got %#v", user.Permissions)
}
})
}
+15 -8
View File
@@ -16,11 +16,11 @@ import (
"strings"
"time"
libErrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/quota"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
libErrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
"github.com/gtsteffaniak/filebrowser/backend/internal/quota"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-cache/cache"
@@ -127,7 +127,7 @@ var (
// Per-route-class token buckets (keys: IP or username). Expired entries are dropped by go-cache.
var (
authRateLimitCredentialByIP = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
authRateLimitCredentialByUsername = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
authRateLimitCredentialByUsername = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
authRateLimitModerateByIP = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
authRateLimitOIDCByIP = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
authRateLimitAuthenticatedByUser = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
@@ -256,17 +256,24 @@ func SetUserInResponseWriter(w http.ResponseWriter, user *users.User) {
func GetRemoteIP(r *http.Request) string {
cfg := &settings.Config
// Forwarded client headers are only honored when the socket peer is a
// configured trusted proxy; otherwise a direct client could spoof its IP.
trustedPeer := cfg.Http.TrustProxyHeaders && settings.IsTrustedProxyPeer(r)
xff := r.Header.Get("X-Forwarded-For")
if cfg.Http.TrustProxyHeaders && xff != "" {
// Use the rightmost hop: the trusted proxy appends the connecting client, so the
// leftmost value is attacker-controlled when clients send X-Forwarded-For.
if ip := lastForwardedValue(xff); ip != "" {
if trustedPeer && xff != "" {
// Without http.trustedProxies (Unix-socket listener) this returns the
// rightmost XFF entry, the client as recorded by the proxy. With trusted proxies
// configured, the walk skips proxy hops so multi-hop deployments
// (CDN -> reverse proxy -> filebrowser) resolve the real client IP —
// keeping per-IP rate limits and auth lockouts per client, not per edge.
if ip := settings.TrustedProxyClientIP(xff); ip != "" {
return ip
}
}
xri := r.Header.Get("X-Real-IP")
if cfg.Http.TrustProxyHeaders && xri != "" {
if trustedPeer && xri != "" {
return xri
}
-11
View File
@@ -19,17 +19,6 @@ func normalizeHTTPScheme(proto string) string {
}
}
func lastForwardedValue(v string) string {
parts := strings.Split(v, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip != "" {
return ip
}
}
return ""
}
func firstForwardedValue(v string) string {
v = strings.TrimSpace(v)
if i := strings.Index(v, ","); i >= 0 {
+115 -1
View File
@@ -9,8 +9,11 @@ import (
)
func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
// The socket peer must be a trusted proxy for forwarded headers to be honored.
settings.Config.Http.TrustedProxies = []string{"10.0.0.0/8"}
req := httptest.NewRequest("GET", "http://example.com/", nil)
req.RemoteAddr = "10.0.0.1:1234"
@@ -20,6 +23,117 @@ func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
}
}
// With http.trustedProxies set, GetRemoteIP walks the XFF chain past trusted
// proxy hops so multi-hop deployments (CDN -> proxy -> filebrowser) resolve the
// real client IP instead of collapsing everyone onto the shared edge address.
func TestGetRemoteIPTrustedProxies(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
settings.Config.Http.TrustedProxies = []string{"198.51.100.0/24", "10.0.0.0/8"}
tests := []struct {
name string
xff string
want string
}{
{"multi-hop resolves real client", "203.0.113.50, 198.51.100.7", "203.0.113.50"},
{"three hops", "192.0.2.10, 203.0.113.50, 198.51.100.7", "203.0.113.50"},
// Client-supplied XFF entries stay untrusted and cannot spoof identity.
{"spoofed prefix ignored", "1.2.3.4, 203.0.113.50, 198.51.100.7", "203.0.113.50"},
{"empty entries skipped", "203.0.113.50, , 198.51.100.7", "203.0.113.50"},
// Every hop trusted: leftmost is closest to the client.
{"all entries trusted", "198.51.100.7, 198.51.100.8", "198.51.100.7"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/", nil)
req.RemoteAddr = "10.0.0.1:1234"
req.Header.Set("X-Forwarded-For", tt.xff)
if got := GetRemoteIP(req); got != tt.want {
t.Fatalf("GetRemoteIP = %q, want %q", got, tt.want)
}
})
}
}
// Bare IPs (not just CIDRs) are accepted in http.trustedProxies.
func TestGetRemoteIPTrustedProxiesBareIP(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
settings.Config.Http.TrustedProxies = []string{"198.51.100.7", "10.0.0.1"}
req := httptest.NewRequest("GET", "http://example.com/", nil)
req.RemoteAddr = "10.0.0.1:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.50, 198.51.100.7")
if got := GetRemoteIP(req); got != "203.0.113.50" {
t.Fatalf("GetRemoteIP = %q, want 203.0.113.50", got)
}
}
// Without trustProxyHeaders the XFF chain is ignored entirely.
func TestGetRemoteIPIgnoresForwardedForWhenUntrusted(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = false
settings.Config.Http.TrustedProxies = []string{"198.51.100.0/24"}
req := httptest.NewRequest("GET", "http://example.com/", nil)
req.RemoteAddr = "10.0.0.1:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.50, 198.51.100.7")
if got := GetRemoteIP(req); got != "10.0.0.1" {
t.Fatalf("GetRemoteIP = %q, want 10.0.0.1", got)
}
}
// A direct client that is not a trusted proxy cannot spoof its address with
// forwarded headers, even when trustProxyHeaders is enabled.
func TestGetRemoteIPIgnoresForwardedHeadersFromUntrustedPeer(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
settings.Config.Http.TrustedProxies = []string{"198.51.100.0/24"}
req := httptest.NewRequest("GET", "http://example.com/", nil)
req.RemoteAddr = "192.0.2.99:1234"
req.Header.Set("X-Forwarded-For", "1.2.3.4")
req.Header.Set("X-Real-IP", "5.6.7.8")
if got := GetRemoteIP(req); got != "192.0.2.99" {
t.Fatalf("GetRemoteIP = %q, want 192.0.2.99", got)
}
}
// With no trusted proxies configured, forwarded headers are never honored.
func TestGetRemoteIPIgnoresForwardedHeadersWithoutTrustedProxies(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
settings.Config.Http.TrustedProxies = nil
req := httptest.NewRequest("GET", "http://example.com/", nil)
req.RemoteAddr = "10.0.0.1:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.50")
if got := GetRemoteIP(req); got != "10.0.0.1" {
t.Fatalf("GetRemoteIP = %q, want 10.0.0.1", got)
}
}
// An IPv4-mapped IPv6 trusted hop in the XFF chain must still be skipped.
func TestGetRemoteIPMappedTrustedHop(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.TrustProxyHeaders = true
settings.Config.Http.TrustedProxies = []string{"::ffff:198.51.100.0/120"}
req := httptest.NewRequest("GET", "http://example.com/", nil)
req.RemoteAddr = "198.51.100.1:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.50, ::ffff:198.51.100.7")
if got := GetRemoteIP(req); got != "203.0.113.50" {
t.Fatalf("GetRemoteIP = %q, want 203.0.113.50", got)
}
}
func TestGetScheme(t *testing.T) {
t.Run("trusted proto", func(t *testing.T) {
settings.Config.Http.TrustProxyHeaders = true
+23
View File
@@ -17,6 +17,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/quota"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"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-logger/logger"
)
@@ -313,3 +314,25 @@ func renderQuotaError(w http.ResponseWriter, r *http.Request, err error) (int, e
}
return status, nil
}
func prepareDeleteQuotaAdjustments(d *Context, sourceName, fullIndexPath string, fileInfo *iteminfo.ExtendedFileInfo) state.DeleteAdjustments {
sourceInfo, ok := settings.Config.Server.NameToSource[sourceName]
if !ok || fileInfo == nil {
return state.DeleteAdjustments{}
}
principal := quota.PrincipalForUpload(d.User, d.Share.UserID, d.Share.Hash)
isDir := fileInfo.Type == "directory"
var deleteBytes int64
if isDir {
deleteBytes, _ = state.IndexedPathBytes(sourceName, fullIndexPath, true)
} else {
deleteBytes = fileInfo.Size
}
return state.PrepareAccountedDelete(principal, sourceName, sourceInfo.Path, fullIndexPath, d.Share.Hash, isDir, deleteBytes)
}
func applyDeleteQuotaAdjustments(adj state.DeleteAdjustments) {
if err := state.ApplyAccountedDelete(adj); err != nil {
logger.Warningf("quota adjust after delete failed: %v", err)
}
}
+10 -15
View File
@@ -279,27 +279,16 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *Context) (
// delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
principal := quota.PrincipalForUpload(d.User, d.Share.UserID, d.Share.Hash)
sourceInfo, ok := settings.Config.Server.NameToSource[source]
if !ok {
if _, ok := settings.Config.Server.NameToSource[source]; !ok {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
var deleteBytes int64
if fileInfo.Type == "directory" {
deleteBytes, _ = state.IndexedPathBytes(source, fileInfo.Path, true)
} else {
deleteBytes = fileInfo.Size
}
adjustments := prepareDeleteQuotaAdjustments(d, source, fullIndexPath, fileInfo)
err = files.DeleteFiles(source, userScope, fullIndexPath, fileInfo.Type == "directory")
if err != nil {
return ErrToStatus(err), err
}
if deleteBytes > 0 {
if adjErr := state.ApplyAccountedUsageDelta(principal, source, sourceInfo.Path, fileInfo.Path, d.Share.Hash, -deleteBytes); adjErr != nil {
logger.Warningf("quota adjust after delete failed: %v", adjErr)
}
}
applyDeleteQuotaAdjustments(adjustments)
activity.RecordDelete(r, toActor(d), source, path)
return http.StatusOK, nil
@@ -326,11 +315,14 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
shareRelPath := strings.TrimPrefix(d.Share.Path, userScope)
fullIndexPath := utils.JoinScopedIndexPath(userScope, d.IndexPath)
boundIndexPath := utils.JoinScopedIndexPath(userScope, shareRelPath)
sourceName := d.Share.GetSourceName()
adjustments := prepareDeleteQuotaAdjustments(d, sourceName, fullIndexPath, fileInfo)
err = files.DeleteFiles(d.Share.SourcePath, boundIndexPath, fullIndexPath, fileInfo.Type == "directory")
if err != nil {
logger.Errorf("public delete handler: error deleting resource with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("an error occured while deleting the resource")
}
applyDeleteQuotaAdjustments(adjustments)
preview.DelThumbs(r.Context(), *fileInfo)
activity.RecordDelete(r, toActor(d), d.Share.GetSourceName(), fileInfo.Path)
return http.StatusOK, nil
@@ -497,7 +489,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
boundIndexPath := utils.JoinScopedIndexPath(userScope, withoutUserScope)
fullIndexPath := utils.JoinScopedIndexPath(userScope, indexPath)
// Delete the file/directory
adjustments := prepareDeleteQuotaAdjustments(d, sourceName, fullIndexPath, fileInfo)
err = files.DeleteFiles(sourceName, boundIndexPath, fullIndexPath, fileInfo.Type == "directory")
if err != nil {
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
@@ -508,6 +500,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
})
continue
}
applyDeleteQuotaAdjustments(adjustments)
// Delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
activityPath = fileInfo.Path
@@ -577,6 +570,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
continue
}
fullIndexPath := utils.JoinScopedIndexPath(userScope, sanitizedPath)
adjustments := prepareDeleteQuotaAdjustments(d, item.Source, fullIndexPath, fileInfo)
err = files.DeleteFiles(item.Source, userScope, fullIndexPath, fileInfo.Type == "directory")
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
@@ -586,6 +580,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
})
continue
}
applyDeleteQuotaAdjustments(adjustments)
preview.DelThumbs(r.Context(), *fileInfo)
}
// Success — echo the client path in the response
@@ -0,0 +1,186 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// Use a database-backed index because deletion refreshes and removes metadata.
func setupDeleteQuotaTest(t *testing.T) string {
t.Helper()
sourcePath := setupPutQuotaTest(t)
settings.Config.Http.BaseURL = "/"
fileutils.SetFsPermissions(0644, 0755)
oldDB := indexing.GetIndexDB()
settings.Config.Server.CacheDir = t.TempDir()
if err := os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "sql"), 0755); err != nil {
t.Fatal(err)
}
db, _, err := dbsql.NewIndexDB("delete_quota", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
indexing.SetIndexDBForTesting(oldDB)
if err := db.Close(); err != nil {
t.Error(err)
}
})
indexing.SetIndexDBForTesting(db)
source := settings.Config.Server.NameToSource["Downloads"]
source.Config.UseLogicalSize = true
source.Config.ResolvedRules.IndexingDisabled = true
indexing.Initialize(source, true, false)
indexing.GetIndex("Downloads").Config.ResolvedRules.IndexingDisabled = false
return sourcePath
}
// seedNestedQuotaFixture writes files under scope, indexes them, and creates accounted
// quotas on public/nested (5 bytes used) and sibling (7 bytes used).
func seedNestedQuotaFixture(t *testing.T, sourcePath, scope string, user *users.User) (nested, sibling *quota.FolderQuota) {
t.Helper()
for path, content := range map[string]string{"public/direct": "123", "public/nested/file": "12345", "sibling/file": "1234567"} {
full := filepath.Join(sourcePath, scope, path)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
if err := indexing.GetIndex("Downloads").RefreshDirectory("/", true); err != nil {
t.Fatal(err)
}
nested, err := state.CreateFolderQuota(sourcePath, utils.JoinPathAsUnix(scope, "/public/nested"), user.ID, 100, "accounted")
if err != nil {
t.Fatal(err)
}
sibling, err = state.CreateFolderQuota(sourcePath, utils.JoinPathAsUnix(scope, "/sibling"), user.ID, 100, "accounted")
if err != nil {
t.Fatal(err)
}
for path, size := range map[string]int64{"/public/nested/file": 5, "/sibling/file": 7} {
if err = state.ApplyAccountedUsageDelta(user, "Downloads", sourcePath, utils.JoinPathAsUnix(scope, path), "", size); err != nil {
t.Fatal(err)
}
}
return nested, sibling
}
// The API path is relative to the user's scope while quota paths and the index are
// source-relative, so the scoped case checks that the delete joins the scope.
func TestResourceDeleteNestedQuota(t *testing.T) {
for _, scope := range []string{"/", "/scoped"} {
t.Run(scope, func(t *testing.T) {
sourcePath := setupDeleteQuotaTest(t)
if preview.GetService() == nil {
if err := preview.StartPreviewGenerator(1, t.TempDir()); err != nil {
t.Fatal(err)
}
}
user, err := state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
user.BackendScopes[0].Scope = scope
nested, sibling := seedNestedQuotaFixture(t, sourcePath, scope, &user)
req := httptest.NewRequest(http.MethodDelete, "/api/resources?source=Downloads&path=/public", nil)
status, err := resourceDeleteHandler(httptest.NewRecorder(), req, &requestContext{User: &user})
if err != nil || status != http.StatusOK {
t.Fatalf("delete: status=%d err=%v", status, err)
}
if _, err := os.Stat(filepath.Join(sourcePath, scope, "public")); !os.IsNotExist(err) {
t.Fatalf("ancestor still exists: %v", err)
}
if used, _ := state.GetQuotaCounterSnapshot(nested.ID); used != 0 {
t.Fatalf("nested usage=%d, want 0", used)
}
if used, _ := state.GetQuotaCounterSnapshot(sibling.ID); used != 7 {
t.Fatalf("sibling usage=%d, want 7", used)
}
})
}
}
func TestResourceBulkDeleteNestedQuota(t *testing.T) {
for _, scope := range []string{"/", "/scoped"} {
t.Run(scope, func(t *testing.T) {
sourcePath := setupDeleteQuotaTest(t)
if preview.GetService() == nil {
if err := preview.StartPreviewGenerator(1, t.TempDir()); err != nil {
t.Fatal(err)
}
}
user, err := state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
user.BackendScopes[0].Scope = scope
nested, sibling := seedNestedQuotaFixture(t, sourcePath, scope, &user)
body, err := json.Marshal([]BulkDeleteItem{{Source: "Downloads", Path: "/public"}})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodDelete, "/api/resources/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
status, err := ResourceBulkDeleteHandler(rec, req, &requestContext{User: &user})
if err != nil || status != http.StatusOK {
t.Fatalf("bulk delete: status=%d err=%v body=%s", status, err, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(sourcePath, scope, "public")); !os.IsNotExist(err) {
t.Fatalf("ancestor still exists: %v", err)
}
if used, _ := state.GetQuotaCounterSnapshot(nested.ID); used != 0 {
t.Fatalf("nested usage=%d, want 0", used)
}
if used, _ := state.GetQuotaCounterSnapshot(sibling.ID); used != 7 {
t.Fatalf("sibling usage=%d, want 7", used)
}
})
}
}
func TestResourceBulkDeletePartialFailureKeepsQuota(t *testing.T) {
sourcePath := setupDeleteQuotaTest(t)
user, err := state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
nested, sibling := seedNestedQuotaFixture(t, sourcePath, "/", &user)
body, err := json.Marshal([]BulkDeleteItem{
{Source: "Downloads", Path: "/no-such-path"},
{Source: "Downloads", Path: "/public"},
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodDelete, "/api/resources/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
status, err := ResourceBulkDeleteHandler(rec, req, &requestContext{User: &user})
if err != nil || status != http.StatusMultiStatus {
t.Fatalf("bulk delete: status=%d err=%v", status, err)
}
if used, _ := state.GetQuotaCounterSnapshot(nested.ID); used != 0 {
t.Fatalf("nested usage=%d, want 0 after successful item", used)
}
if used, _ := state.GetQuotaCounterSnapshot(sibling.ID); used != 7 {
t.Fatalf("sibling usage=%d, want 7", used)
}
}
+7 -4
View File
@@ -16,8 +16,8 @@ import (
_ "net/http/pprof"
"github.com/coreos/go-systemd/v22/activation"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/events"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
@@ -40,6 +40,8 @@ var (
func initRuntime(deps Deps, assets fs.FS) {
runtimeDeps = deps
assetFs = assets
// Make the generated YAML's `secret:` markers available to the settings redactor.
initSettingsSecretMarkers()
}
// Embed the files in the frontend/dist directory
@@ -91,8 +93,9 @@ func StartHttp(ctx context.Context, deps Deps, shutdownComplete chan struct{}) {
configureHTTPRouter(router, api, publicRoutes, publicApi)
srv := &http.Server{
Addr: settings.HTTPListenAddr(settings.Config.Http.ListenAddress, settings.Config.Http.Port),
Handler: muxWithMiddleware(router),
Addr: settings.HTTPListenAddr(settings.Config.Http.ListenAddress, settings.Config.Http.Port),
Handler: muxWithMiddleware(router),
ConnContext: settings.AttachConnContext,
}
listenAddress := settings.Config.Http.ListenAddress
go func() {
@@ -103,7 +106,7 @@ func StartHttp(ctx context.Context, deps Deps, shutdownComplete chan struct{}) {
if err = os.Remove(settings.Config.Http.Socket); err != nil && !os.IsNotExist(err) {
logger.Fatalf("Could not remove existing socket: %v", err)
}
listener, err = net.Listen("unix", settings.Config.Http.Socket)
listener, err = settings.ListenUnixFilesystem(settings.Config.Http.Socket)
if err != nil {
logger.Fatalf("Could not listen on unix socket: %v", err)
}
+40 -17
View File
@@ -33,6 +33,19 @@ func readSettingsPatchBody(w http.ResponseWriter, r *http.Request) ([]byte, erro
return patchJSON, nil
}
// initSettingsSecretMarkers points the settings package at the generated YAML shipped in the
// running binary's asset filesystem, so it can discover the `secret:` comment markers that
// decide which config fields are redacted. The Go sources are absent in a deployed binary, so
// this is the marker source that matters in production.
func initSettingsSecretMarkers() {
if settings.SecretHintYaml != "" {
return
}
if embedded, err := readEmbeddedConfigYaml(); err == nil {
settings.SecretHintYaml = embedded
}
}
// settingsGetHandler retrieves the current system settings.
// @Summary Get system settings
// @Description Returns the current configuration settings for signup, user directories, rules, frontend.
@@ -44,6 +57,9 @@ func readSettingsPatchBody(w http.ResponseWriter, r *http.Request) ([]byte, erro
// @Router /api/settings [get]
func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
property := r.URL.Query().Get("property")
if property == "auth" {
return RenderJSON(w, r, settings.RedactAuthSecrets(settings.Config.Auth))
}
if property != "" {
// get property by name
switch property {
@@ -51,8 +67,6 @@ func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
return RenderJSON(w, r, state.GetDefaultUserDefaults())
case "frontend":
return RenderJSON(w, r, settings.Config.Frontend)
case "auth":
return RenderJSON(w, r, settings.Config.Auth)
case "server":
return RenderJSON(w, r, settings.Config.Server)
case "sources":
@@ -61,7 +75,26 @@ func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
return http.StatusNotFound, nil
}
}
return RenderJSON(w, r, &settings.Config)
// Redact secrets (auth.key, totpSecret, adminPassword, OIDC clientSecret, LDAP/recaptcha
// secrets, ...) so the admin response never echoes plaintext credentials (GHSA-892f-9h5p-mfc8).
// RedactedSettingsCopy returns a copy: the live config keeps the real secrets, so auth and
// the OIDC/LDAP/OnlyOffice integrations keep working after this request.
return RenderJSON(w, r, settings.RedactedSettingsCopy(&settings.Config))
}
// readEmbeddedConfigYaml returns the config.generated.yaml shipped in the binary's asset
// filesystem. It is the comment/secret-marker source for both the YAML export and the settings
// JSON redactor.
func readEmbeddedConfigYaml() (string, error) {
path := "config.generated.yaml"
if settings.Env.EmbeddedFs {
path = "embed/config.generated.yaml"
}
content, err := fs.ReadFile(assetFs, path)
if err != nil {
return "", err
}
return string(content), nil
}
// settingsConfigHandler returns the current system settings as YAML.
@@ -85,22 +118,12 @@ func settingsConfigHandler(w http.ResponseWriter, r *http.Request, d *Context) (
var err error
var yamlConfig string
// Try to read the generated YAML file for comments (should always exist)
var embeddedYaml []byte
var readErr error
if settings.Env.EmbeddedFs {
embeddedYaml, readErr = fs.ReadFile(assetFs, "embed/config.generated.yaml")
} else {
embeddedYaml, readErr = fs.ReadFile(assetFs, "config.generated.yaml")
if readErr != nil {
return http.StatusInternalServerError, fmt.Errorf("error reading generated YAML: %v", readErr)
}
}
// Comment/secret-marker source, shared with settingsGetHandler's redactor.
embeddedYaml, _ := readEmbeddedConfigYaml()
// If we successfully read the generated YAML, use it as the comment source
if readErr == nil && len(embeddedYaml) > 0 {
yamlConfig, err = settings.GenerateConfigYamlWithEmbedded(&settings.Config, showComments, showFull, false, string(embeddedYaml))
if embeddedYaml != "" {
yamlConfig, err = settings.GenerateConfigYamlWithEmbedded(&settings.Config, showComments, showFull, false, embeddedYaml)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating YAML: %v", err)
}
@@ -0,0 +1,124 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
const settingsRedactSentinel = "SENTINEL-SETTINGS-SECRET"
// TestSettingsGetHandler_redactsSecrets is the end-to-end regression guard for
// GHSA-892f-9h5p-mfc8: GET /api/settings (and ?property=auth) must not return cleartext secrets.
func TestSettingsGetHandler_redactsSecrets(t *testing.T) {
setupTestEnv(t)
prev := settings.Config
t.Cleanup(func() { settings.Config = prev })
settings.Config.Auth.Key = settingsRedactSentinel
settings.Config.Auth.TotpSecret = settingsRedactSentinel
settings.Config.Auth.Methods.PasswordAuth.AdminPassword = settingsRedactSentinel
settings.Config.Auth.Methods.PasswordAuth.Recaptcha.Secret = settingsRedactSentinel
settings.Config.Auth.Methods.OidcAuth.ClientSecret = settingsRedactSentinel
settings.Config.Auth.Methods.LdapAuth.UserPassword = settingsRedactSentinel
settings.Config.Auth.Methods.JwtAuth.Secret = settingsRedactSentinel
settings.Config.Integrations.OnlyOffice.Secret = settingsRedactSentinel
admin := &users.User{
FrontendUser: users.FrontendUser{
Username: "settings_redact_admin",
Permissions: users.Permissions{Admin: true},
},
}
for _, path := range []string{"/api/settings", "/api/settings?property=auth"} {
t.Run(path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
status, err := settingsGetHandler(rec, req, &Context{User: admin})
if status != http.StatusOK {
t.Fatalf("status=%d err=%v want 200", status, err)
}
body := rec.Body.String()
if strings.Contains(body, settingsRedactSentinel) {
t.Fatalf("response for %s leaked a plaintext secret: %s", path, body)
}
if !json.Valid([]byte(body)) {
t.Fatalf("response for %s is not valid JSON: %s", path, body)
}
})
}
}
// TestSettingsGetHandler_doesNotMutateLiveConfig is the regression guard for the live-config
// mutation: the full settings request must redact only its response, never settings.Config,
// otherwise JWT signing and the integrations break until the process restarts.
func TestSettingsGetHandler_doesNotMutateLiveConfig(t *testing.T) {
setupTestEnv(t)
prev := settings.Config
t.Cleanup(func() { settings.Config = prev })
settings.Config.Auth.Key = settingsRedactSentinel
admin := &users.User{
FrontendUser: users.FrontendUser{
Username: "settings_redact_admin3",
Permissions: users.Permissions{Admin: true},
},
}
req := httptest.NewRequest(http.MethodGet, "/api/settings", nil)
rec := httptest.NewRecorder()
status, err := settingsGetHandler(rec, req, &Context{User: admin})
if status != http.StatusOK {
t.Fatalf("status=%d err=%v want 200", status, err)
}
if strings.Contains(rec.Body.String(), settingsRedactSentinel) {
t.Fatal("response leaked a plaintext secret")
}
if settings.Config.Auth.Key != settingsRedactSentinel {
t.Fatalf("live config auth key was mutated by the settings response: %q", settings.Config.Auth.Key)
}
}
// TestSettingsGetHandler_redactsAuthKeyOnlyWherePresent confirms non-secret auth fields still
// round-trip so the response stays usable for the UI.
func TestSettingsGetHandler_keepsNonSecretAuthFields(t *testing.T) {
setupTestEnv(t)
prev := settings.Config
t.Cleanup(func() { settings.Config = prev })
settings.Config.Auth.Key = settingsRedactSentinel
settings.Config.Auth.TokenExpirationHours = 7
admin := &users.User{
FrontendUser: users.FrontendUser{
Username: "settings_redact_admin2",
Permissions: users.Permissions{Admin: true},
},
}
req := httptest.NewRequest(http.MethodGet, "/api/settings?property=auth", nil)
rec := httptest.NewRecorder()
status, err := settingsGetHandler(rec, req, &Context{User: admin})
if status != http.StatusOK {
t.Fatalf("status=%d err=%v want 200", status, err)
}
var got settings.Auth
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal auth: %v", err)
}
if got.TokenExpirationHours != 7 {
t.Fatalf("tokenExpirationHours=%d want 7", got.TokenExpirationHours)
}
if got.Key == settingsRedactSentinel {
t.Fatal("auth key was returned in cleartext")
}
}
+5 -2
View File
@@ -8,6 +8,7 @@ import (
"mime"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
@@ -72,13 +73,15 @@ func normalizeViewGrantPath(p string) string {
// shareRelativeDisplayName returns the client-facing filename for a path within a share.
// Single-file shares use file=/ at the share root; the real name comes from the share path.
// Both paths are slash-separated, so use path.Base: on Windows filepath.Base("/")
// returns "\", which would skip the share-name fallback below.
func shareRelativeDisplayName(d *Context, shareRelativePath string) string {
name := filepath.Base(normalizeViewGrantPath(shareRelativePath))
name := path.Base(normalizeViewGrantPath(shareRelativePath))
if name != "" && name != "." && name != "/" {
return name
}
if d != nil && d.Share.Hash != "" {
if shareName := filepath.Base(d.Share.Path); shareName != "" && shareName != "." && shareName != "/" {
if shareName := path.Base(normalizeViewGrantPath(d.Share.Path)); shareName != "" && shareName != "." && shareName != "/" {
return shareName
}
}
+2 -2
View File
@@ -197,7 +197,7 @@ func TestValidateViewGrantShareBinding(t *testing.T) {
}
t.Cleanup(func() { settings.Config.Server.SourceMap = nil })
d := &requestContext{
User: testUserWithView(1, "srv"),
User: testUserWithView(1, "srv"),
Share: share.Share{
ShareColumns: share.ShareColumns{Hash: "abc123"},
SourcePath: "/srv",
@@ -215,7 +215,7 @@ func TestValidateViewGrantShareBinding(t *testing.T) {
t.Fatalf("ValidateViewGrant: %v", err)
}
wrongShare := &requestContext{
User: testUserWithView(1, "srv"),
User: testUserWithView(1, "srv"),
Share: share.Share{
ShareColumns: share.ShareColumns{Hash: "other"},
SourcePath: "/srv",
+72 -24
View File
@@ -34,6 +34,29 @@ func createTokenAuthUser(t *testing.T, username string, perms users.Permissions)
return &reloaded
}
// registerApiToken mints a named API token for user, stores its metadata, and
// registers its hash so auth can resolve it. configure, when non-nil, may adjust
// the metadata before it is persisted.
func registerApiToken(t *testing.T, user *users.User, name string, caps users.Permissions, minimal bool, configure func(*users.AuthToken)) string {
t.Helper()
tokenString, tokenMeta, err := auth.MakeSignedTokenAPI(user, name, time.Hour, caps, minimal)
if err != nil {
t.Fatal(err)
}
tokenMeta.Name = name
tokenMeta.Token = tokenString
if configure != nil {
configure(&tokenMeta)
}
if err = state.AddUserToken(user.Username, tokenMeta); err != nil {
t.Fatal(err)
}
if err = state.AddApiToken(tokenString, user.ID); err != nil {
t.Fatal(err)
}
return tokenString
}
func cookieRequest(t *testing.T, handler http.HandlerFunc, token string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
@@ -67,6 +90,53 @@ func TestApiTokenWithoutMetadataRejected(t *testing.T) {
}
}
func TestApiTokenMinimalKeepsOwnerPermissions(t *testing.T) {
setupTestEnv(t)
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
user := createTokenAuthUser(t, "minimal-admin", users.Permissions{Admin: true, Api: true})
tokenString := registerApiToken(t, user, "minimal-key", users.Permissions{}, true, nil)
rec := cookieRequest(t, withAdmin(mockHandler), tokenString)
if rec.Code != http.StatusOK {
t.Fatalf("minimal API token: got status %d, want %d", rec.Code, http.StatusOK)
}
}
func TestApiTokenCustomizedZeroPermissionsForbiddenOnAdmin(t *testing.T) {
setupTestEnv(t)
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
user := createTokenAuthUser(t, "custom-zero-admin", users.Permissions{Admin: true, Api: true})
tokenString := registerApiToken(t, user, "zero-key", users.Permissions{}, false, nil)
rec := cookieRequest(t, withAdmin(mockHandler), tokenString)
if rec.Code != http.StatusForbidden {
t.Fatalf("customized zero-cap API token: got status %d, want %d", rec.Code, http.StatusForbidden)
}
}
func TestApiTokenLegacyEmptyCapsTreatedAsMinimal(t *testing.T) {
setupTestEnv(t)
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
user := createTokenAuthUser(t, "legacy-minimal-admin", users.Permissions{Admin: true, Api: true})
tokenString := registerApiToken(t, user, "legacy-key", users.Permissions{}, true, func(meta *users.AuthToken) {
meta.Minimal = nil
})
rec := cookieRequest(t, withAdmin(mockHandler), tokenString)
if rec.Code != http.StatusOK {
t.Fatalf("legacy empty-cap API token: got status %d, want %d", rec.Code, http.StatusOK)
}
}
// A named API token with stored caps must have those caps applied at auth time.
func TestApiTokenCapsAppliedFromStoredMetadata(t *testing.T) {
setupTestEnv(t)
@@ -77,18 +147,7 @@ func TestApiTokenCapsAppliedFromStoredMetadata(t *testing.T) {
user := createTokenAuthUser(t, "capped-admin", users.Permissions{Admin: true, Api: true})
// The token cap excludes admin, so the resolved user must lose admin.
tokenString, tokenMeta, err := auth.MakeSignedTokenAPI(user, "capped-key", time.Hour, users.Permissions{Share: true}, false)
if err != nil {
t.Fatal(err)
}
tokenMeta.Name = "capped-key"
tokenMeta.Token = tokenString
if err := state.AddUserToken(user.Username, tokenMeta); err != nil {
t.Fatal(err)
}
if err := state.AddApiToken(tokenString, user.ID); err != nil {
t.Fatal(err)
}
tokenString := registerApiToken(t, user, "capped-key", users.Permissions{Share: true}, false, nil)
rec := cookieRequest(t, withAdmin(mockHandler), tokenString)
if rec.Code != http.StatusForbidden {
@@ -228,18 +287,7 @@ func TestApiTokenBasicAuthRoundTrip(t *testing.T) {
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
user := createTokenAuthUser(t, "webdav-user", users.Permissions{Api: true})
tokenString, tokenMeta, err := auth.MakeSignedTokenAPI(user, "obsidian-sync", time.Hour, users.Permissions{}, true)
if err != nil {
t.Fatal(err)
}
tokenMeta.Name = "obsidian-sync"
tokenMeta.Token = tokenString
if err = state.AddUserToken(user.Username, tokenMeta); err != nil {
t.Fatal(err)
}
if err = state.AddApiToken(tokenString, user.ID); err != nil {
t.Fatal(err)
}
tokenString := registerApiToken(t, user, "obsidian-sync", users.Permissions{}, true, nil)
basicRequest := func(token string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
+3
View File
@@ -406,6 +406,9 @@ func userPatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
if err2 != nil {
return http.StatusInternalServerError, err2
}
if !d.User.Permissions.Admin && uValue.LockPassword && state.FieldListIncludes(req.Which, "password") {
return http.StatusForbidden, fmt.Errorf("password changes are disabled for this user")
}
req.User.ID = uValue.ID
req.User.Username = uValue.Username
if state.FieldListIncludes(req.Which, "otpEnabled") && !req.User.OtpEnabled {
@@ -0,0 +1,85 @@
package web
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
func TestUserPatchLockPassword(t *testing.T) {
setupTestEnv(t)
admin := &users.User{
ID: 1,
FrontendUser: users.FrontendUser{
Username: "admin",
LoginMethod: users.LoginMethodOidc,
Permissions: users.Permissions{Admin: true},
},
}
if err := state.CreateUser(admin, ""); err != nil {
t.Fatal(err)
}
admin.Permissions = users.Permissions{Admin: true}
if err := state.UpdateUser(admin, "", "permissions"); err != nil {
t.Fatal(err)
}
locked := &users.User{
FrontendUser: users.FrontendUser{
Username: "locked",
LoginMethod: users.LoginMethodPassword,
},
}
if err := state.CreateUser(locked, "oldpassword"); err != nil {
t.Fatal(err)
}
locked.LockPassword = true
if err := state.UpdateUser(locked, "", "lockPassword"); err != nil {
t.Fatal(err)
}
locked, err := getUser(t, "locked")
if err != nil {
t.Fatal(err)
}
if !locked.LockPassword {
t.Fatal("lockPassword was not stored")
}
oldHash := locked.Password
patch := func(actor *users.User) int {
body := []byte(`{"which":["password"],"data":{"password":"newpassword"}}`)
req := httptest.NewRequest(http.MethodPatch, "/api/users?username=locked", bytes.NewReader(body))
req.Header.Set("X-Password", "oldpassword")
status, _ := userPatchHandler(httptest.NewRecorder(), req, &Context{User: actor})
return status
}
if status := patch(locked); status != http.StatusForbidden {
t.Fatalf("self password change with lockPassword: status=%d, want %d", status, http.StatusForbidden)
}
after, err := getUser(t, "locked")
if err != nil {
t.Fatal(err)
}
if after.Password != oldHash {
t.Fatal("password changed despite lockPassword")
}
if status := patch(admin); status != http.StatusNoContent {
t.Fatalf("admin password reset of locked user: status=%d, want %d", status, http.StatusNoContent)
}
}
func getUser(t *testing.T, username string) (*users.User, error) {
t.Helper()
u, err := state.GetUserByUsername(username)
if err != nil {
return nil, err
}
return &u, nil
}
+13 -9
View File
@@ -573,7 +573,7 @@ func (ffs *filteredFileSystem) RemoveAll(ctx context.Context, requestPath string
if err := ffs.checkScopePath(requestPath, true); err != nil {
logger.Debugf("RemoveAll: scope check denied for %s: %v", requestPath, err)
return err
}
}
// The API delete path frees the deleted bytes from the quota counters. Do the same
// here: a WebDAV DELETE, and the MOVE over an existing destination that the WebDAV
// library implements as RemoveAll + Rename, otherwise leave the old bytes charged
@@ -587,24 +587,28 @@ func (ffs *filteredFileSystem) RemoveAll(ctx context.Context, requestPath string
}
var deleteBytes int64
var isDir bool
if info, err := ffs.fs.Stat(ctx, requestPath); err != nil {
logger.Debugf("RemoveAll: cannot stat %s before deleting, quota not adjusted: %v", requestPath, err)
} else if info.IsDir() {
isDir = true
deleteBytes, _ = state.IndexedPathBytes(ffs.source, indexPath, true)
} else {
deleteBytes = info.Size()
}
}
// Collect the adjustments before deleting: a directory delete also has to release
// folder quotas nested under it, and their sizes come from the index.
var adjustments state.DeleteAdjustments
if sourceInfo, ok := settings.Config.Server.NameToSource[ffs.source]; ok {
adjustments = state.PrepareAccountedDelete(ffs.user, ffs.source, sourceInfo.Path, indexPath, "", isDir, deleteBytes)
}
if err := ffs.fs.RemoveAll(ctx, requestPath); err != nil {
return err
}
if deleteBytes > 0 {
if sourceInfo, ok := settings.Config.Server.NameToSource[ffs.source]; ok {
if adjErr := state.ApplyAccountedUsageDelta(ffs.user, ffs.source, sourceInfo.Path, indexPath, "", -deleteBytes); adjErr != nil {
logger.Warningf("quota adjust after webdav delete failed: %v", adjErr)
}
}
if err := state.ApplyAccountedDelete(adjustments); err != nil {
logger.Warningf("quota adjust after webdav delete failed: %v", err)
}
activity.RecordWebDAVUser(ffs.httpReq, ffs.user, activitydb.Entry{
EventType: activitydb.EventDelete,
+60
View File
@@ -1,6 +1,7 @@
package web
import (
"context"
"fmt"
"io"
"net/http"
@@ -12,6 +13,8 @@ import (
"testing"
"time"
"golang.org/x/net/webdav"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
databasequota "github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
@@ -1148,6 +1151,63 @@ func TestWebDAV_OPTIONS(t *testing.T) {
})
}
type failedDeleteFileSystem struct{ webdav.FileSystem }
func (failedDeleteFileSystem) RemoveAll(context.Context, string) error { return os.ErrPermission }
func TestWebDAVDeleteNestedQuota(t *testing.T) {
for _, scope := range []string{"/", "/scoped"} {
t.Run(scope, func(t *testing.T) {
sourcePath := setupDeleteQuotaTest(t)
user, err := state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
user.BackendScopes[0].Scope = scope
nested, sibling := seedNestedQuotaFixture(t, sourcePath, scope, &user)
ffs := &filteredFileSystem{fs: failedDeleteFileSystem{webdav.Dir(filepath.Join(sourcePath, scope))}, source: "Downloads", user: &user}
if err = ffs.RemoveAll(context.Background(), "/public"); err == nil {
t.Fatal("expected forbidden deletion")
}
ffs.filePerms = users.SourceFilePermissions{Delete: true}
if err = ffs.RemoveAll(context.Background(), "/public"); err == nil {
t.Fatal("expected underlying deletion failure")
}
if used, _ := state.GetQuotaCounterSnapshot(nested.ID); used != 5 {
t.Fatalf("failed delete changed usage: %d", used)
}
req := httptest.NewRequest(http.MethodDelete, "/dav/Downloads/public", nil)
req.SetPathValue("source", "Downloads")
req.SetPathValue("path", "/public")
rec := httptest.NewRecorder()
_, err = webDAVHandler(rec, req, &requestContext{User: &user})
if err != nil || rec.Code != http.StatusNoContent {
t.Fatalf("delete: code=%d err=%v body=%s", rec.Code, err, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(sourcePath, scope, "public")); !os.IsNotExist(err) {
t.Fatalf("ancestor still exists: %v", err)
}
if used, _ := state.GetQuotaCounterSnapshot(nested.ID); used != 0 {
t.Fatalf("nested usage=%d, want 0", used)
}
if used, _ := state.GetQuotaCounterSnapshot(sibling.ID); used != 7 {
t.Fatalf("sibling usage=%d, want 7", used)
}
req = httptest.NewRequest(http.MethodDelete, "/dav/Downloads/sibling/file", nil)
req.SetPathValue("source", "Downloads")
req.SetPathValue("path", "/sibling/file")
rec = httptest.NewRecorder()
if _, err := webDAVHandler(rec, req, &requestContext{User: &user}); err != nil || rec.Code != http.StatusNoContent {
t.Fatalf("file delete: code=%d err=%v", rec.Code, err)
}
if used, _ := state.GetQuotaCounterSnapshot(sibling.ID); used != 0 {
t.Fatalf("file delete usage=%d, want 0", used)
}
})
}
}
// A WebDAV DELETE must free the deleted bytes from the quota counters, the way the
// API delete path does, or the old bytes stay charged until the next recount.
func TestWebDAV_DeleteFreesQuota(t *testing.T) {
+45
View File
@@ -0,0 +1,45 @@
package indexing
import "sync"
// pathCoalescer runs background work at most once per key at a time. A job submitted while
// one for the same key is running replaces any job already queued behind it; the latest
// queued job runs when the current one finishes. Concurrent requests for the same folder
// therefore hold at most one running and one queued goroutine instead of one per request,
// and the newest data is still persisted.
type pathCoalescer struct {
mu sync.Mutex
pending map[string]*func()
}
// submit runs fn in the background, coalesced with other work for key.
func (c *pathCoalescer) submit(key string, fn func()) {
c.mu.Lock()
if c.pending == nil {
c.pending = make(map[string]*func())
}
if slot, running := c.pending[key]; running {
*slot = fn
c.mu.Unlock()
return
}
slot := new(func())
c.pending[key] = slot
c.mu.Unlock()
go func() {
for {
fn()
c.mu.Lock()
next := *slot
if next == nil {
delete(c.pending, key)
c.mu.Unlock()
return
}
*slot = nil
c.mu.Unlock()
fn = next
}
}()
}
+68
View File
@@ -0,0 +1,68 @@
package indexing
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestPathCoalescerRunsLatestAndBoundsConcurrency(t *testing.T) {
var c pathCoalescer
release := make(chan struct{})
started := make(chan struct{})
var running, maxRunning, runs atomic.Int32
var last atomic.Int32
var wg sync.WaitGroup
job := func(id int32) func() {
return func() {
n := running.Add(1)
for {
m := maxRunning.Load()
if n <= m || maxRunning.CompareAndSwap(m, n) {
break
}
}
if id == 0 {
close(started)
<-release
}
last.Store(id)
runs.Add(1)
running.Add(-1)
wg.Done()
}
}
wg.Add(1)
c.submit("/a/", job(0))
<-started
for i := int32(1); i <= 5; i++ {
c.submit("/a/", job(i))
}
wg.Add(1) // only the latest queued job (5) runs after the first
close(release)
wg.Wait()
if got := runs.Load(); got != 2 {
t.Fatalf("runs = %d, want 2 (running job + latest queued)", got)
}
if got := last.Load(); got != 5 {
t.Fatalf("last job = %d, want 5", got)
}
if got := maxRunning.Load(); got != 1 {
t.Fatalf("max concurrent = %d, want 1", got)
}
// The final job's wg.Done runs before the coalescer drops the key, so poll briefly.
var left int
for deadline := time.Now().Add(2 * time.Second); ; {
c.mu.Lock()
left = len(c.pending)
c.mu.Unlock()
if left == 0 || time.Now().After(deadline) {
break
}
time.Sleep(time.Millisecond)
}
if left != 0 {
t.Fatalf("pending keys left = %d, want 0", left)
}
}
+35 -9
View File
@@ -3,6 +3,8 @@
package indexing
import (
"errors"
"io/fs"
"os"
"runtime"
"sync"
@@ -106,35 +108,52 @@ const dirStatParallelThreshold = 256
// slow filesystems do not overwhelm the kernel or I/O stack.
const dirStatMaxWorkers = 16
// statEntry is statDirEntry behind a seam so tests can inject per-entry stat failures (EACCES, EIO)
// that are hard to produce on a real filesystem.
var statEntry = statDirEntry
// readDirFileInfos lists dir as FileInfos, statting each name relative to the open directory fd.
//
// An entry whose stat fails with a permission or not-exist error is skipped (see skipEntry), so one
// unreadable junction does not fail the whole folder. Any other error fails the listing. If every name
// was skipped as not-exist, the listing fails instead of returning an empty success.
func readDirFileInfos(dir *os.File) ([]os.FileInfo, error) {
names, err := dir.Readdirnames(-1)
if err != nil {
return nil, err
}
dirPath := dir.Name()
dirFd := int(dir.Fd())
useStatx := statxDirListingAvailable(dirFd)
if len(names) < dirStatParallelThreshold {
infos := make([]os.FileInfo, 0, len(names))
vanished := 0
for _, name := range names {
fi, err := statDirEntry(dirFd, name, useStatx)
fi, err := statEntry(dirFd, name, useStatx)
if err != nil {
if os.IsNotExist(err) {
continue
if !skipEntry(dirPath, name, err) {
return nil, err
}
return nil, err
if errors.Is(err, fs.ErrNotExist) {
vanished++
}
continue
}
infos = append(infos, fi)
}
if err := checkNotAllVanished(dirPath, len(names), vanished); err != nil {
return nil, err
}
return infos, nil
}
return statDirEntriesParallel(dirFd, names, useStatx)
return statDirEntriesParallel(dirPath, dirFd, names, useStatx)
}
// statDirEntriesParallel stats each name relative to dirFd across a bounded
// worker pool. Entry order matches names; entries that vanished are skipped.
func statDirEntriesParallel(dirFd int, names []string, useStatx bool) ([]os.FileInfo, error) {
func statDirEntriesParallel(dirPath string, dirFd int, names []string, useStatx bool) ([]os.FileInfo, error) {
workers := runtime.GOMAXPROCS(0) * 2
if workers > dirStatMaxWorkers {
workers = dirStatMaxWorkers
@@ -145,6 +164,7 @@ func statDirEntriesParallel(dirFd int, names []string, useStatx bool) ([]os.File
results := make([]os.FileInfo, len(names))
var next atomic.Int64
var vanished atomic.Int64
var errMu sync.Mutex
var firstErr error
recordErr := func(err error) {
@@ -173,12 +193,15 @@ func statDirEntriesParallel(dirFd int, names []string, useStatx bool) ([]os.File
if i >= len(names) {
return
}
fi, err := statDirEntry(dirFd, names[i], useStatx)
fi, err := statEntry(dirFd, names[i], useStatx)
switch {
case err == nil:
results[i] = fi
case os.IsNotExist(err):
// Entry disappeared between readdir and stat; leave nil.
case skipEntry(dirPath, names[i], err):
// Left nil: unreadable or vanished between readdir and stat.
if errors.Is(err, fs.ErrNotExist) {
vanished.Add(1)
}
default:
recordErr(err)
return
@@ -191,6 +214,9 @@ func statDirEntriesParallel(dirFd int, names []string, useStatx bool) ([]os.File
if firstErr != nil {
return nil, firstErr
}
if err := checkNotAllVanished(dirPath, len(names), int(vanished.Load())); err != nil {
return nil, err
}
infos := make([]os.FileInfo, 0, len(names))
for _, fi := range results {
if fi != nil {
@@ -3,9 +3,12 @@
package indexing
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"syscall"
"testing"
"time"
@@ -260,3 +263,259 @@ func TestReadDirFileInfos_Parallel(t *testing.T) {
}
}
}
// writeNumberedFiles creates count files in dir and returns their names.
func writeNumberedFiles(t *testing.T, dir string, count int) []string {
t.Helper()
names := make([]string, 0, count)
for i := 0; i < count; i++ {
name := fmt.Sprintf("file_%04d", i)
if err := os.WriteFile(filepath.Join(dir, name), []byte{byte(i % 251)}, 0o644); err != nil {
t.Fatal(err)
}
names = append(names, name)
}
return names
}
func openTestDir(t *testing.T, dir string) *os.File {
t.Helper()
f, err := os.Open(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { f.Close() })
return f
}
// injectStatErr makes the stat of entry bad fail with err, on both the serial and parallel paths.
func injectStatErr(t *testing.T, bad string, err error) {
t.Helper()
orig := statEntry
t.Cleanup(func() { statEntry = orig })
statEntry = func(dirFd int, name string, useStatx bool) (os.FileInfo, error) {
if name == bad {
return nil, &fs.PathError{Op: "fstatat", Path: name, Err: err}
}
return orig(dirFd, name, useStatx)
}
}
// injectAllStatErr makes every entry's stat fail with err.
func injectAllStatErr(t *testing.T, err error) {
t.Helper()
orig := statEntry
t.Cleanup(func() { statEntry = orig })
statEntry = func(dirFd int, name string, useStatx bool) (os.FileInfo, error) {
return nil, &fs.PathError{Op: "fstatat", Path: name, Err: err}
}
}
// listingSizes covers the serial path (below dirStatParallelThreshold) and the parallel path.
var listingSizes = []int{3, dirStatParallelThreshold + 44}
func TestReadDirFileInfos_SkipsPermissionDeniedEntry(t *testing.T) {
for _, count := range listingSizes {
t.Run(fmt.Sprintf("entries=%d", count), func(t *testing.T) {
dir := t.TempDir()
names := writeNumberedFiles(t, dir, count)
injectStatErr(t, names[0], syscall.EACCES)
infos, err := readDirFileInfos(openTestDir(t, dir))
if err != nil {
t.Fatalf("one EACCES entry failed the listing: %v", err)
}
if len(infos) != count-1 {
t.Fatalf("listed %d entries, want %d", len(infos), count-1)
}
for _, fi := range infos {
if fi.Name() == names[0] {
t.Fatal("unreadable entry was listed")
}
}
})
}
}
func TestReadDirFileInfos_SkipsVanishedEntry(t *testing.T) {
for _, count := range listingSizes {
t.Run(fmt.Sprintf("entries=%d", count), func(t *testing.T) {
dir := t.TempDir()
names := writeNumberedFiles(t, dir, count)
injectStatErr(t, names[0], syscall.ENOENT)
infos, err := readDirFileInfos(openTestDir(t, dir))
if err != nil {
t.Fatalf("one vanished entry failed the listing: %v", err)
}
if len(infos) != count-1 {
t.Fatalf("listed %d entries, want %d", len(infos), count-1)
}
})
}
}
// EIO on a network mount must fail the listing; skipping it would let the scanner prune files that
// still exist.
func TestReadDirFileInfos_IOErrorFailsListing(t *testing.T) {
for _, count := range listingSizes {
t.Run(fmt.Sprintf("entries=%d", count), func(t *testing.T) {
dir := t.TempDir()
names := writeNumberedFiles(t, dir, count)
injectStatErr(t, names[count/2], syscall.EIO)
if _, err := readDirFileInfos(openTestDir(t, dir)); !errors.Is(err, syscall.EIO) {
t.Fatalf("got %v, want EIO to fail the listing", err)
}
})
}
}
func TestReadDirFileInfos_StaleHandleFailsListing(t *testing.T) {
dir := t.TempDir()
names := writeNumberedFiles(t, dir, 3)
injectStatErr(t, names[1], syscall.ESTALE)
if _, err := readDirFileInfos(openTestDir(t, dir)); !errors.Is(err, syscall.ESTALE) {
t.Fatalf("got %v, want ESTALE to fail the listing", err)
}
}
// If every entry vanished, the directory was removed or renamed mid-scan. An empty success would make
// the scanner prune all of its children.
func TestReadDirFileInfos_AllEntriesVanishedFails(t *testing.T) {
for _, count := range listingSizes {
t.Run(fmt.Sprintf("entries=%d", count), func(t *testing.T) {
dir := t.TempDir()
writeNumberedFiles(t, dir, count)
injectAllStatErr(t, syscall.ENOENT)
f := openTestDir(t, dir)
// The directory itself is renamed away mid-scan; the open handle still lists it.
if err := os.Rename(dir, dir+".moved"); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir + ".moved") })
if _, err := readDirFileInfos(f); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("got %v, want a not-exist error for an all-vanished listing", err)
}
})
}
}
// Every entry vanishing from a directory that still exists (short-lived temp files) is a valid
// empty listing, not a not-exist error for the directory.
func TestReadDirFileInfos_AllEntriesVanishedDirPresentIsEmpty(t *testing.T) {
dir := t.TempDir()
writeNumberedFiles(t, dir, 1)
injectAllStatErr(t, syscall.ENOENT)
infos, err := readDirFileInfos(openTestDir(t, dir))
if err != nil {
t.Fatalf("got %v, want an empty success", err)
}
if len(infos) != 0 {
t.Fatalf("listed %d entries, want 0", len(infos))
}
}
func TestReadDirFileInfos_EmptyDirIsNotAnError(t *testing.T) {
infos, err := readDirFileInfos(openTestDir(t, t.TempDir()))
if err != nil || len(infos) != 0 {
t.Fatalf("empty dir: got %d entries, err %v", len(infos), err)
}
}
// Real EACCES, no seam: a directory that is readable but not searchable lists its names, while each
// per-entry stat fails with EACCES. Root ignores directory permissions, so skip there.
func TestReadDirFileInfos_RealPermissionDenied(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores directory permissions")
}
dir := t.TempDir()
writeNumberedFiles(t, dir, 2)
if err := os.Chmod(dir, 0o444); err != nil {
t.Fatal(err)
}
// Registered after TempDir's cleanup, so it runs first and the directory can be removed.
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
infos, err := readDirFileInfos(openTestDir(t, dir))
if err != nil {
t.Fatalf("unsearchable directory failed the listing: %v", err)
}
if len(infos) != 0 {
t.Fatalf("listed %d entries, want 0", len(infos))
}
}
func TestCheckNotAllVanished(t *testing.T) {
if err := checkNotAllVanished("/d", 0, 0); err != nil {
t.Errorf("empty listing: got %v, want nil", err)
}
if err := checkNotAllVanished("/d", 3, 2); err != nil {
t.Errorf("partial vanish: got %v, want nil", err)
}
gone := filepath.Join(t.TempDir(), "gone")
if err := checkNotAllVanished(gone, 3, 3); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("all vanished, directory gone: got %v, want not-exist", err)
}
// A one-entry directory whose only entry was deleted mid-scan still exists: empty success.
if err := checkNotAllVanished(t.TempDir(), 1, 1); err != nil {
t.Errorf("all vanished, directory present: got %v, want nil", err)
}
// A stat failure other than not-exist (here ENOTDIR) must fail the listing, not report empty.
file := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(file, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := checkNotAllVanished(filepath.Join(file, "child"), 1, 1); err == nil {
t.Error("all vanished, stat error: got nil, want error")
}
}
func TestSkipEntry_Classification(t *testing.T) {
dir := t.TempDir()
if !skipEntry(dir, "perm", syscall.EACCES) {
t.Error("EACCES should skip")
}
if !skipEntry(dir, "gone", syscall.ENOENT) {
t.Error("ENOENT should skip")
}
if skipEntry(dir, "io", syscall.EIO) {
t.Error("EIO must not skip")
}
if skipEntry(dir, "stale", syscall.ESTALE) {
t.Error("ESTALE must not skip")
}
// A vanished entry is not recorded as unreadable.
unreadableMu.Lock()
_, recorded := unreadableWarned[filepath.Join(dir, "gone")]
unreadableMu.Unlock()
if recorded {
t.Error("ENOENT must not be recorded in the warned set")
}
}
func TestSkipEntry_WarnedSetIsBounded(t *testing.T) {
origMax := maxUnreadableWarned
t.Cleanup(func() {
maxUnreadableWarned = origMax
unreadableMu.Lock()
clear(unreadableWarned)
unreadableMu.Unlock()
})
maxUnreadableWarned = 2
dir := t.TempDir()
for _, name := range []string{"a", "b", "c"} {
skipEntry(dir, name, syscall.EACCES)
}
unreadableMu.Lock()
defer unreadableMu.Unlock()
if len(unreadableWarned) > 2 {
t.Fatalf("warned set holds %d paths, bound is 2", len(unreadableWarned))
}
if _, ok := unreadableWarned[filepath.Join(dir, "c")]; !ok {
t.Error("latest path should be recorded after the set was cleared")
}
}
+75 -2
View File
@@ -2,8 +2,81 @@
package indexing
import "os"
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
)
// readDirFileInfos lists dir as FileInfos. Each entry is stat'ed with DirEntry.Info(), which on Windows
// reuses the directory read data and on Unix lstats by path. An entry skipped by skipEntry (permission
// or not-exist) is left out; any other error fails the listing. If every name was skipped as not-exist
// (e.g. the directory was renamed mid-scan), the listing fails instead of returning an empty success.
func readDirFileInfos(dir *os.File) ([]os.FileInfo, error) {
return dir.Readdir(-1)
dirPath := dir.Name()
entries, err := dir.ReadDir(-1)
if err != nil {
// On Unix, ReadDir lstats an entry itself when the filesystem reports an unknown d_type, and
// stops at the first failure. Only that per-entry lstat error (permission or not-exist) falls
// back to listing by name; errors reading the directory itself are returned.
var pe *fs.PathError
if !errors.As(err, &pe) || pe.Op != "lstat" ||
!(errors.Is(err, fs.ErrPermission) || errors.Is(err, fs.ErrNotExist)) {
return nil, err
}
return readDirByName(dir, err)
}
infos := make([]os.FileInfo, 0, len(entries))
vanished := 0
for _, e := range entries {
fi, err := e.Info()
if err != nil {
if !skipEntry(dirPath, e.Name(), err) {
return nil, err
}
if errors.Is(err, fs.ErrNotExist) {
vanished++
}
continue
}
infos = append(infos, fi)
}
if err := checkNotAllVanished(dirPath, len(entries), vanished); err != nil {
return nil, err
}
return infos, nil
}
// readDirByName re-reads dir by name and lstats each entry, skipping those skipEntry allows. origErr is
// the ReadDir error returned if the directory cannot be re-read.
func readDirByName(dir *os.File, origErr error) ([]os.FileInfo, error) {
if _, err := dir.Seek(0, io.SeekStart); err != nil {
return nil, origErr
}
names, err := dir.Readdirnames(-1)
if err != nil {
return nil, origErr
}
dirPath := dir.Name()
infos := make([]os.FileInfo, 0, len(names))
vanished := 0
for _, name := range names {
fi, err := os.Lstat(filepath.Join(dirPath, name))
if err != nil {
if !skipEntry(dirPath, name, err) {
return nil, err
}
if errors.Is(err, fs.ErrNotExist) {
vanished++
}
continue
}
infos = append(infos, fi)
}
if err := checkNotAllVanished(dirPath, len(names), vanished); err != nil {
return nil, err
}
return infos, nil
}
+81
View File
@@ -0,0 +1,81 @@
package indexing
import (
"errors"
"io/fs"
"os"
"sync"
"github.com/gtsteffaniak/go-logger/logger"
)
// Skip policy for per-entry stat errors while listing a directory.
//
// One entry that cannot be stat'ed should not fail the whole listing. On a CIFS or SMB mount a Windows
// junction can point at a target the share user cannot read, and the whole folder would otherwise
// disappear from the UI and the index. Like `ls -l`, we skip just that entry.
//
// Only permission and not-exist errors skip an entry. Any other error (EIO, ESTALE, timeouts) still
// fails the listing: the scanner prunes entries it does not see, so skipping on a flaky mount would
// delete index rows for files that still exist.
//
// A skipped entry is treated as absent by the scanner: it is pruned from the index and its size is
// left out of the folder total, the same as `ls` shows it as unreadable.
// maxUnreadableWarned bounds the set of paths already warned about. Routine scans revisit the same
// entries forever, so the set is cleared when it fills rather than growing without limit.
var maxUnreadableWarned = 10000
var (
unreadableMu sync.Mutex
unreadableWarned = map[string]struct{}{}
)
// skipEntry reports whether a stat error for dirPath/name should skip that entry rather than fail the
// listing. Permission errors are logged once per path (then at debug level), so a persistently
// unreadable entry does not flood the log on every scan.
func skipEntry(dirPath, name string, err error) bool {
if errors.Is(err, fs.ErrNotExist) {
return true
}
if !errors.Is(err, fs.ErrPermission) {
return false
}
path := dirPath + "/" + name
unreadableMu.Lock()
_, seen := unreadableWarned[path]
if !seen {
if len(unreadableWarned) >= maxUnreadableWarned {
clear(unreadableWarned)
}
unreadableWarned[path] = struct{}{}
}
unreadableMu.Unlock()
if seen {
logger.Debugf("skipping unreadable entry %s: %v", path, err)
} else {
logger.Warningf("skipping unreadable entry %s: %v", path, err)
}
return true
}
// checkNotAllVanished fails a listing in which every one of total names was skipped as not-exist.
// That is what a directory renamed or removed mid-scan looks like when entries are stat'ed by path.
// Returning an empty success there would make the scanner prune all of its children.
// When dirPath itself still exists, its entries really were deleted (e.g. short-lived temp
// files), so the empty listing is correct and is returned as a success.
func checkNotAllVanished(dirPath string, total, vanished int) error {
if total == 0 || vanished != total {
return nil
}
_, err := os.Stat(dirPath)
if err == nil {
return nil
}
if !errors.Is(err, fs.ErrNotExist) {
// Existence is unknown (I/O or permission error): fail the scan so indexed
// children are preserved instead of pruned by an empty "successful" listing.
return err
}
return &fs.PathError{Op: "readdir", Path: dirPath, Err: fs.ErrNotExist}
}
+52 -12
View File
@@ -119,6 +119,7 @@ type Index struct {
previousNumFiles uint64 // Track previous NumFiles to use when scan in progress (computed value is 0)
scanners map[string]*Scanner // path -> scanner
mock bool
bgWork pathCoalescer // per-path background persistence and refreshes
mu sync.RWMutex
activeScannerPath string
schedulerStop chan struct{}
@@ -137,6 +138,9 @@ type Index struct {
// Adaptive scheduler: shared slot map (UTC unix seconds -> scanners).
scheduleSlotsMu sync.Mutex
scheduleSlots map[int64][]*Scanner
// startupScanPending requests one bounded serial scan pass on startup when the
// persisted folder-size map could not be restored (quick scans would be blind).
startupScanPending bool
}
var (
@@ -640,10 +644,27 @@ func (idx *Index) getDirInfoFromContext(ctx *PathContext, isViewable, isIndexabl
if err != nil {
return nil, err
}
idx.persistDirectoryListing(ctx.IndexPath, indexableInfo, opts)
// Persist index rows and folder sizes in the background so API listing
// latency stays independent of index DB write throughput. Coalesced per path so a slow
// DB cannot accumulate one goroutine per request.
indexPath := ctx.IndexPath
idx.bgWork.submit("persist|"+indexPath, func() {
idx.persistDirectoryListing(indexPath, indexableInfo, opts)
})
return dirFileInfo, nil
}
// RefreshDirectoryAsync runs RefreshDirectory(indexPath, false) in the background, coalesced
// per path: while a refresh for indexPath runs, further requests queue at most one more
// refresh instead of starting parallel directory reads and index DB writes.
func (idx *Index) RefreshDirectoryAsync(indexPath string) {
idx.bgWork.submit("refresh|"+indexPath, func() {
if err := idx.RefreshDirectory(indexPath, false); err != nil {
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
}
})
}
// persistDirectoryListing updates index rows and the in-memory folder size from
// an already-built directory listing, avoiding the second directory walk that
// RefreshDirectory performs. Listings without extended attributes carry
@@ -653,12 +674,7 @@ func (idx *Index) persistDirectoryListing(indexPath string, indexableInfo *itemi
return
}
idx.UpdateMetadata(indexableInfo, nil, true)
newSize := uint64(indexableInfo.Size)
if previousSize, exists := idx.GetFolderSize(indexPath); exists {
idx.updateFolderSizeAndParents(indexPath, newSize, previousSize, true)
} else {
idx.SetFolderSize(indexPath, newSize)
}
idx.updateFolderSizeDelta(indexPath, uint64(indexableInfo.Size))
}
// getBasicDirInfo returns just directory metadata without children
@@ -710,8 +726,14 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne
idx.UpdateMetadata(dirFileInfo, scanner, !opts.Recursive)
// Always store the calculated directory size in the in-memory map
// This ensures parent directories are updated after copy/move operations
idx.SetFolderSize(indexPath, uint64(dirFileInfo.Size))
// This ensures parent directories are updated after copy/move operations.
// API-driven refreshes (no scanner) also propagate the delta to ancestors so
// parent folder sizes reflect changes below the refreshed directory.
if scanner == nil {
idx.updateFolderSizeDelta(indexPath, uint64(dirFileInfo.Size))
} else {
idx.SetFolderSize(indexPath, uint64(dirFileInfo.Size))
}
return dirFileInfo.Size, dirFileInfo.HasPreview, nil
}
@@ -1363,8 +1385,29 @@ func (idx *Index) RefreshDirectory(indexPath string, recursive bool) error {
return err
}
// updateFolderSizeDelta atomically records a folder's newly computed size and
// propagates the delta to all ancestors in the in-memory folderSizes map.
func (idx *Index) updateFolderSizeDelta(indexPath string, newSize uint64) {
idx.folderSizesMu.Lock()
defer idx.folderSizesMu.Unlock()
previousSize, exists := idx.folderSizes[indexPath]
if !exists {
idx.folderSizes[indexPath] = newSize
idx.folderSizesUnsynced[indexPath] = struct{}{}
return
}
idx.updateFolderSizeAndParentsLocked(indexPath, newSize, previousSize, true)
}
// updateFolderSizeAndParents updates a folder size and propagates the change to all parents
func (idx *Index) updateFolderSizeAndParents(path string, newSize uint64, previousSize uint64, updateTarget bool) {
idx.folderSizesMu.Lock()
defer idx.folderSizesMu.Unlock()
idx.updateFolderSizeAndParentsLocked(path, newSize, previousSize, updateTarget)
}
// updateFolderSizeAndParentsLocked is updateFolderSizeAndParents with folderSizesMu already held.
func (idx *Index) updateFolderSizeAndParentsLocked(path string, newSize uint64, previousSize uint64, updateTarget bool) {
// Calculate the delta
var sizeDelta int64
if newSize >= previousSize {
@@ -1377,9 +1420,6 @@ func (idx *Index) updateFolderSizeAndParents(path string, newSize uint64, previo
return // No change, nothing to propagate
}
idx.folderSizesMu.Lock()
defer idx.folderSizesMu.Unlock()
// Optionally update the target folder itself
if updateTarget {
idx.folderSizes[path] = newSize
+4
View File
@@ -631,6 +631,10 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
// Update metadata (will be batched)
s.idx.UpdateMetadata(dirInfo, s, true)
// Propagate the freshly computed folder size to ancestors so parent
// directories reflect changes below this directory.
s.idx.updateFolderSizeDelta(folderPath, uint64(dirInfo.Size))
// Track this directory as having been updated (for file purge logic)
s.idx.mu.Lock()
if s.idx.scanUpdatedPaths == nil {
+28 -7
View File
@@ -5,9 +5,9 @@ import (
"strconv"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
indexingdb "github.com/gtsteffaniak/filebrowser/backend/internal/database/dbindex"
"github.com/gtsteffaniak/filebrowser/backend/internal/events"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -244,24 +244,32 @@ func (idx *Index) SendSourceUpdateEvent() error {
// restoreFolderSizes loads the persisted directory sizes into the in-memory folderSizes map.
// Quick scans iterate that map to decide which folders to stat, so leaving it empty after a
// restart would make them blind until the next full scan.
func (idx *Index) restoreFolderSizes() {
// restart would make them blind until the next full scan. Returns false when nothing could
// be restored (load failure or no persisted directory rows).
func (idx *Index) restoreFolderSizes() bool {
if idx.db == nil {
return
return false
}
sizes, err := idx.db.LoadFolderSizes(idx.Name)
if err != nil {
logger.Errorf("[%s] Failed to load persisted folder sizes: %v", idx.Name, err)
return
return false
}
idx.folderSizesMu.Lock()
idx.folderSizes = sizes
idx.folderSizesMu.Unlock()
return len(sizes) > 0
}
// setupMultiScanner creates and starts the multi-scanner system
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
func (idx *Index) setupMultiScanner(isNewDb bool) {
idx.setupMultiScannerEx(isNewDb, true)
}
// setupMultiScannerEx is setupMultiScanner with the scheduler goroutine made optional —
// startSched=false lets tests inspect restored state without racing a running scheduler.
func (idx *Index) setupMultiScannerEx(isNewDb bool, startSched bool) {
// Load persisted index-level stats from Bolt (complexity, numDirs/Files, used/usedAlt/total, etc.)
// Scanner-level stats below use a second GetByPath only for the Scanners map.
if err := idx.Load(); err != nil {
@@ -269,8 +277,9 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
}
// Quick scans only stat the directories present in folderSizes, which is in-memory only.
// Restoring it keeps change detection working after a restart without a full re-read.
sizesRestored := false
if !isNewDb {
idx.restoreFolderSizes()
sizesRestored = idx.restoreFolderSizes()
}
idx.mu.Lock()
@@ -313,6 +322,16 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
// Discover existing top-level directories (root scanner will create scanners for new ones dynamically)
topLevelDirs := rootScanner.getTopLevelDirs()
// Quick scans stat only the directories present in folderSizes. When the index
// was previously built but no folder sizes could be restored while directories
// exist, quick scans would run blind — and their unchanged-modtime path would
// write zero sizes back — so run one bounded serial pass to rebuild the map.
if !isNewDb && !sizesRestored && len(topLevelDirs) > 0 {
idx.mu.Lock()
idx.startupScanPending = true
idx.mu.Unlock()
}
// Create child scanner for each top-level directory
for _, dirPath := range topLevelDirs {
childScanner := idx.createScanner(dirPath)
@@ -341,7 +360,9 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
}
idx.restoreScannerNextRuns()
go idx.runIndexScheduler()
if startSched {
go idx.runIndexScheduler()
}
logger.Debugf("Created %d scanners for [%v] (1 root + %d children)", len(topLevelDirs)+1, idx.Name, len(topLevelDirs))
}
@@ -120,6 +120,12 @@ func TestFolderSizesRestoredOnStartup(t *testing.T) {
if size != 4242 {
t.Fatalf("restored size for /sub/ = %d, want 4242", size)
}
idx.mu.RLock()
pending := idx.startupScanPending
idx.mu.RUnlock()
if pending {
t.Fatal("restored folder sizes must not trigger the startup bootstrap pass")
}
}
// fullScanCounter decides quick vs full for the next run; dropping it on restart means every
@@ -161,6 +167,86 @@ func TestFullScanCounterSurvivesRestart(t *testing.T) {
}
}
// When persisted folder sizes cannot be restored but the source has directories, quick scans
// would stat nothing (they iterate folderSizes) — so a bounded serial pass must be scheduled.
func TestMissingFolderSizesTriggersBootstrap(t *testing.T) {
idx := newRestoreTestIndex(t, "missing_sizes", []string{"sub"}, persistedScanners("/", "/sub/"))
idx.setupMultiScannerEx(false, false)
idx.mu.RLock()
pending := idx.startupScanPending
idx.mu.RUnlock()
if !pending {
t.Fatal("unrestored folder sizes with existing dirs must schedule a bootstrap pass")
}
if !idx.needsAdaptiveBootstrap() {
t.Fatal("startupScanPending must make the adaptive scheduler run the serial pass")
}
}
// A scanner that missed a scheduled run while the process was down must run a full scan first:
// in-place file edits update file mtimes but not parent directory mtimes, so quick scans
// cannot detect them.
func TestOverdueScannerRunsFullScan(t *testing.T) {
idx := newRestoreTestIndex(t, "overdue", nil, nil)
s := &Scanner{
scanPath: "/",
idx: idx,
currentSchedule: 0, // fastest tier: 5m — a lastScanned an hour ago is overdue
lastScanned: time.Now().Add(-time.Hour),
fullScanCounter: 2,
}
idx.scanners = map[string]*Scanner{"/": s}
idx.restoreScannerNextRuns()
var counter int
s.withStatsRLock(func() { counter = s.fullScanCounter })
if counter != 0 {
t.Fatalf("overdue scanner fullScanCounter = %d, want 0 (full scan first)", counter)
}
}
// Serial bootstrap must reset fullScanCounter so the pass runs full scans and can rebuild
// folderSizes; a restored counter in the quick-scan range would stat nothing.
func TestSerialBootstrapPassResetsFullScanCounter(t *testing.T) {
idx := newRestoreTestIndex(t, "serial_reset", nil, persistedScanners("/"))
// No background scheduler: the test drives runSerialScanPass itself, and a running scheduler
// would scan the same scanner concurrently (data race on its stats).
idx.setupMultiScannerEx(false, false)
idx.mu.Lock()
idx.startupScanPending = true
scanner := idx.scanners["/"]
idx.mu.Unlock()
scanner.withStatsLock(func() {
scanner.fullScanCounter = 2
})
idx.runSerialScanPass()
var counter int
scanner.withStatsRLock(func() { counter = scanner.fullScanCounter })
if counter != 1 {
t.Fatalf("after bootstrap full scan fullScanCounter = %d, want 1", counter)
}
}
// A scanner still within its schedule window keeps its persisted quick/full cadence.
func TestOnScheduleScannerKeepsScanCounter(t *testing.T) {
idx := newRestoreTestIndex(t, "on_schedule", nil, nil)
s := &Scanner{
scanPath: "/",
idx: idx,
currentSchedule: 0,
lastScanned: time.Now(),
fullScanCounter: 2,
}
idx.scanners = map[string]*Scanner{"/": s}
idx.restoreScannerNextRuns()
var counter int
s.withStatsRLock(func() { counter = s.fullScanCounter })
if counter != 2 {
t.Fatalf("on-schedule scanner fullScanCounter = %d, want 2", counter)
}
}
// An index with no persisted scan times must still do the serial pass, or a new or empty index
// would never be scanned.
func TestEmptyIndexStillBootstrapsSerialPass(t *testing.T) {
@@ -33,6 +33,14 @@ func computeNextSlotTime(lastScanned, ref time.Time, interval time.Duration) tim
return alignCeilToInterval(minNext, interval)
}
// scannerMissedAlignedSlot reports whether at least one aligned scan slot after
// lastScanned has passed before now (e.g. process was down across a due time).
func scannerMissedAlignedSlot(lastScanned, now time.Time, tier int) bool {
d := scanScheduleDuration(tier)
due := computeNextSlotTime(lastScanned, lastScanned, d)
return due.Before(now)
}
func (idx *Index) removeScannerFromSlotLocked(s *Scanner) {
sec := s.calendarSlotSec
if sec == 0 {
@@ -145,6 +153,13 @@ func (idx *Index) restoreScannerNextRuns() {
d := scanScheduleDuration(tier)
next = computeNextSlotTime(s.lastScanned, now, d)
s.nextRun = next
if scannerMissedAlignedSlot(s.lastScanned, now, tier) {
// This scanner missed at least one aligned slot while the process
// was down. In-place file edits update file mtimes but not their
// parent directory mtime, so quick scans cannot see them — force
// the first run after restart to be a full scan.
s.fullScanCounter = 0
}
})
if !next.IsZero() {
idx.registerScannerNextRun(s, next)
@@ -197,6 +212,9 @@ func (idx *Index) pruneGoneChildScanners() {
func (idx *Index) needsAdaptiveBootstrap() bool {
idx.mu.RLock()
defer idx.mu.RUnlock()
if idx.startupScanPending {
return true
}
for _, s := range idx.scanners {
if s.lastScanned.IsZero() {
return true
@@ -231,6 +249,10 @@ func (idx *Index) runSerialScanPass() {
s.removeSelf()
continue
}
// Folder sizes are not restored in this pass, so run a full scan.
s.withStatsLock(func() {
s.fullScanCounter = 0
})
s.executeScan()
}
}
@@ -239,6 +261,9 @@ func (idx *Index) schedulerAdaptiveTick() {
idx.pruneGoneChildScanners()
if idx.needsAdaptiveBootstrap() {
logger.Debugf("[%s] scheduler: adaptive bootstrap (serial pass all scanners)", idx.Name)
idx.mu.Lock()
idx.startupScanPending = false
idx.mu.Unlock()
idx.runSerialScanPass()
return
}
@@ -53,6 +53,18 @@ func TestScheduleTierBoundsForComplexity(t *testing.T) {
}
}
func TestScannerMissedAlignedSlot(t *testing.T) {
loc := time.UTC
last := time.Date(2026, 4, 14, 10, 1, 0, 0, loc)
now := time.Date(2026, 4, 14, 10, 7, 0, 0, loc)
if scannerMissedAlignedSlot(last, now, 0) {
t.Fatal("next aligned slot is still in the future; should not count as missed")
}
if !scannerMissedAlignedSlot(last, time.Date(2026, 4, 14, 10, 11, 0, 0, loc), 0) {
t.Fatal("aligned slot 10:10 should be treated as missed at 10:11")
}
}
func TestComputeNextSlotTimeHourUsesHourGrid(t *testing.T) {
loc := time.UTC
last := time.Date(2026, 4, 14, 12, 0, 0, 0, loc)
+2 -2
View File
@@ -135,7 +135,7 @@ type ProxyAuthConfig struct {
type Recaptcha struct {
Host string `json:"host" validate:"required"` // google recaptcha host, for example: https://www.google.com/recaptcha/api.js
Key string `json:"key" validate:"required"` // v2 site key
Secret string `json:"secret" validate:"required"` // v2 secret key
Secret string `json:"secret" validate:"required"` // secret: v2 secret key
}
// ValidateRecaptcha disables recaptcha at startup if the configured host isn't a valid https URL
@@ -168,7 +168,7 @@ type LdapConfig struct {
Server string `json:"server"` // scheme://host:port of the LDAP server (e.g. ldap://localhost:389)
BaseDN string `json:"baseDN"` // LDAP search base DN (e.g. dc=ldap,dc=goauthentik,dc=io)
UserDN string `json:"userDN"` // Bind DN for service account (e.g. cn=admin,ou=users,dc=ldap,dc=goauthentik,dc=io)
UserPassword string `json:"userPassword"` // Bind password for service account
UserPassword string `json:"userPassword"` // secret: bind password for the LDAP service account
UserFilter string `json:"userFilter"` // Search filter for finding user by username. Default (&(cn=%s)(objectClass=user)); override e.g. (email=%s) or (sAMAccountName=%s) for other directories.
Port int `json:"-"` // derived from server
Scheme string `json:"-"` // derived from server
+12 -2
View File
@@ -113,7 +113,18 @@ func warnHttpProxyConfig() {
return
}
if Config.Http.BaseURL != "/" && !Config.Http.TrustProxyHeaders {
logger.Warning(`http.baseURL is not "/" but http.trustProxyHeaders is false. Behind a reverse proxy on a subpath, set trustProxyHeaders: true so cookies, client IP, OIDC redirects, and URLs resolve correctly. See https://filebrowserquantum.com/en/docs/configuration/http/#trustproxyheaders`)
logger.Warning(`http.baseURL is not "/" but http.trustProxyHeaders is false. Behind a reverse proxy on a subpath, set trustProxyHeaders: true so cookies, OIDC redirects, and URLs resolve correctly, and list the proxy addresses in http.trustedProxies so the client IP resolves too. See https://filebrowserquantum.com/en/docs/configuration/http/#trustproxyheaders`)
}
if len(Config.Http.TrustedProxies) > 0 && !Config.Http.TrustProxyHeaders {
logger.Warning("http.trustedProxies is set but http.trustProxyHeaders is false; trusted proxy entries are ignored until trustProxyHeaders is enabled")
}
// Check the parsed list: blank entries are accepted by validation but yield no prefixes.
if Config.Http.TrustProxyHeaders && len(TrustedProxyPrefixes()) == 0 && Config.Http.Socket == "" {
logger.Warning("http.trustProxyHeaders is true but http.trustedProxies is empty; X-Forwarded-For and X-Real-IP are ignored, so client IPs (rate limits, activity logs) resolve to the proxy address. Add your reverse proxy addresses to http.trustedProxies.")
}
if Config.Http.TrustProxyHeaders && Config.Http.Socket != "" && !strings.HasPrefix(Config.Http.Socket, "@") &&
len(Config.Http.UnixSocketTrustedUIDs) == 0 && len(Config.Http.UnixSocketTrustedGIDs) == 0 {
logger.Warning("http.trustProxyHeaders is true and http.socket is a filesystem Unix socket, but http.unixSocketTrustedUIDs and http.unixSocketTrustedGIDs are empty; X-Forwarded-For and X-Real-IP are ignored until you list the reverse proxy's UID or GID (Linux peer credentials).")
}
if !Env.IsDevMode {
if u := Config.Http.ExternalUrl; u != "" && strings.HasPrefix(strings.ToLower(u), "http://") {
@@ -382,7 +393,6 @@ func isGoTest() bool {
return flag.Lookup("test.v") != nil
}
func setupFFmpegIntegration() {
n := Config.Server.NumImageProcessors
if n < 1 {
+282 -12
View File
@@ -1041,6 +1041,11 @@ func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFul
deprecated = make(DeprecatedFieldsMap)
}
// The embedded YAML omits deprecated fields entirely, so the marker map has no entry for a
// deprecated field that is also a secret (e.g. the top-level auth.adminUsername/adminPassword).
// Union in the field-name fallback so those are redacted too.
identifySecretFieldsByReflection(reflect.ValueOf(config), "Settings", secrets)
var node *yaml.Node
if showFull {
@@ -1081,6 +1086,282 @@ func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFul
return yamlOutput, nil
}
// redactPlaceholder replaces secret values in API responses without revealing the original.
const redactPlaceholder = "**hidden**"
// RedactedSettingsCopy returns a deep copy of the given config with every secret-marked field
// replaced by the redaction placeholder. The live config is never modified, so callers can
// render the result to an API response without disabling authentication (a mutated
// settings.Config would break JWT signing/verification and the OIDC/LDAP/OnlyOffice integrations
// until restart). See GHSA-892f-9h5p-mfc8.
func RedactedSettingsCopy(conf *Settings) *Settings {
if conf == nil {
return nil
}
cpVal := deepCopyValue(reflect.ValueOf(conf))
cp, ok := cpVal.Interface().(*Settings)
if !ok {
return nil
}
secrets := buildSecretFieldsFor(conf)
redactSecretsValue(reflect.ValueOf(cp), "Settings", secrets)
return cp
}
// RedactAuthSecrets returns a copy of the auth section with secret fields replaced by the
// redaction placeholder, leaving the live config untouched.
func RedactAuthSecrets(auth Auth) Auth {
cp := auth
secrets := buildSecretFieldsFor(&Settings{Auth: Config.Auth})
redactSecretsValue(reflect.ValueOf(&cp), "Auth", secrets)
return cp
}
// deepCopyValue returns a deep copy of v, preserving the exact field values (including unexported
// fields) rather than round-tripping through JSON, which would run custom unmarshalers and change
// semantics (e.g. users.Permissions.UnmarshalJSON sets Configured).
func deepCopyValue(v reflect.Value) reflect.Value {
switch v.Kind() {
case reflect.Pointer:
if v.IsNil() {
return v
}
cp := reflect.New(v.Type().Elem())
cp.Elem().Set(deepCopyValue(v.Elem()))
return cp
case reflect.Interface:
if v.IsNil() {
return v
}
cp := deepCopyValue(v.Elem())
out := reflect.New(v.Type()).Elem()
out.Set(cp)
return out
case reflect.Struct:
out := reflect.New(v.Type()).Elem()
t := v.Type()
for i := 0; i < t.NumField(); i++ {
dst := out.Field(i)
if dst.CanSet() {
dst.Set(deepCopyValue(v.Field(i)))
}
}
return out
case reflect.Slice:
if v.IsNil() {
return v
}
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(deepCopyValue(v.Index(i)))
}
return out
case reflect.Array:
out := reflect.New(v.Type()).Elem()
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(deepCopyValue(v.Index(i)))
}
return out
case reflect.Map:
if v.IsNil() {
return v
}
out := reflect.MakeMapWithSize(v.Type(), v.Len())
iter := v.MapRange()
for iter.Next() {
out.SetMapIndex(deepCopyValue(iter.Key()), deepCopyValue(iter.Value()))
}
return out
default:
return v
}
}
// SecretFieldNames are struct field names treated as secret regardless of comment markers.
// Used both for YAML redaction and for redacting GET /api/settings responses.
var SecretFieldNames = map[string]bool{
"Key": true,
"AdminUsername": true,
"AdminPassword": true,
"TotpSecret": true,
"ClientID": true,
"ClientSecret": true,
"Secret": true,
"UserPassword": true,
"Password": true,
"ApiKey": true,
"APIKey": true,
"BearerToken": true,
"PrivateKey": true,
}
// SecretHintYaml holds the embedded config.generated.yaml text used to discover `secret:`
// comment markers at runtime. The web layer sets it from the running binary's asset
// filesystem; the settings sources are not present in a deployed binary.
var SecretHintYaml string
// ActiveSecretFields returns the struct type -> field name -> is secret mapping for the running
// config. It combines the explicit `secret:` comment markers (from the embedded YAML when
// available, otherwise the Go sources) with the SecretFieldNames reflection fallback, so a new
// field named ApiKey/Secret/etc. is caught even without a marker.
func ActiveSecretFields() SecretFieldsMap {
return buildSecretFieldsFor(&Config)
}
func buildSecretFieldsFor(conf *Settings) SecretFieldsMap {
var secrets SecretFieldsMap
// Prefer the embedded YAML: it is the only marker source available in a deployed binary,
// and the generator fixes the comments for every field real users configure.
if SecretHintYaml != "" {
if _, s, _, err := CollectCommentsFromEmbeddedYaml(SecretHintYaml); err == nil && hasAnySecret(s) {
secrets = s
}
}
if !hasAnySecret(secrets) {
// Fall back to the Go sources (repo layout / tests). CollectCommentsAndSecrets also
// unions in the reflection fallback, so this path never under-reports.
// Try each candidate directory until one parses with markers, so a missing or
// unparsable first candidate does not skip a valid later one.
for _, srcPath := range settingsSourcePaths() {
if !isSettingsSourceDir(srcPath) {
continue
}
if _, s, _, err := CollectCommentsAndSecrets(srcPath); err == nil && hasAnySecret(s) {
secrets = s
break
}
}
}
if secrets == nil {
secrets = make(SecretFieldsMap)
}
// Always apply the reflection fallback so the map is complete even when only the embedded
// YAML was consulted (it covers the deprecated-but-secret auth.adminUsername/adminPassword
// fields, which the filtered generated YAML omits entirely).
identifySecretFieldsByReflection(reflect.ValueOf(conf), "Settings", secrets)
return secrets
}
// redactSecretsValue walks a settings value and replaces every field marked secret with the
// redaction placeholder, preserving the original structure for the JSON response.
func redactSecretsValue(v reflect.Value, typeName string, secrets SecretFieldsMap) {
switch v.Kind() {
case reflect.Pointer, reflect.Interface:
if v.IsNil() {
return
}
redactSecretsValue(v.Elem(), typeName, secrets)
case reflect.Slice, reflect.Array:
for i := 0; i < v.Len(); i++ {
redactSecretsValue(v.Index(i), typeName, secrets)
}
case reflect.Map:
for _, key := range v.MapKeys() {
redactSecretsValue(v.MapIndex(key), typeName, secrets)
}
case reflect.Struct:
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" {
continue
}
fieldValue := v.Field(i)
if !fieldValue.CanSet() {
continue
}
fieldTypeName := fieldTypeNameFor(field)
switch {
case isSecretField(typeName, field.Name, secrets):
redactStringValue(fieldValue)
case field.Anonymous && jsonTagIsInline(field):
// encoding/json flattens embedded inline structs into the parent object.
redactSecretsValue(fieldValue, fieldTypeName, secrets)
case fieldValue.Kind() == reflect.Struct:
redactSecretsValue(fieldValue, fieldTypeName, secrets)
case fieldValue.Kind() == reflect.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct:
redactSecretsValue(fieldValue, fieldTypeName, secrets)
case fieldValue.Kind() == reflect.Slice && fieldValue.Type().Elem().Kind() == reflect.Struct:
redactSecretsValue(fieldValue, fieldTypeName, secrets)
case fieldValue.Kind() == reflect.Map && fieldValue.Type().Elem().Kind() == reflect.Struct:
redactSecretsValue(fieldValue, fieldTypeName, secrets)
}
}
}
}
func isSecretField(typeName, fieldName string, secrets SecretFieldsMap) bool {
if secrets != nil && secrets[typeName][fieldName] {
return true
}
return SecretFieldNames[fieldName]
}
func fieldTypeNameFor(field reflect.StructField) string {
t := field.Type
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() == reflect.Slice || t.Kind() == reflect.Array || t.Kind() == reflect.Map {
t = t.Elem()
}
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t.Name()
}
func jsonTagIsInline(field reflect.StructField) bool {
tag := field.Tag.Get("json")
return strings.Contains(tag, "inline") || tag == ""
}
// redactStringValue replaces a string-ish secret field's value with the placeholder. Non-empty
// values are replaced; already-empty values are left empty so an unset secret does not appear set.
func redactStringValue(v reflect.Value) {
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return
}
v = v.Elem()
}
if v.Kind() != reflect.String || v.String() == "" {
return
}
v.SetString(redactPlaceholder)
}
// settingsSourcePaths returns candidate directories holding the settings Go sources, used as a
// fallback when the embedded YAML is unavailable during secret detection.
func settingsSourcePaths() []string {
return []string{".", "pkg/settings", "../pkg/settings", "../../pkg/settings"}
}
// hasAnySecret reports whether the map holds at least one field marked secret. Parsers create
// an entry for every struct they see, so a non-empty map alone does not mean markers were found.
func hasAnySecret(secrets SecretFieldsMap) bool {
for _, fields := range secrets {
for _, secret := range fields {
if secret {
return true
}
}
}
return false
}
// isSettingsSourceDir reports whether dir looks like the settings package sources, so an
// unrelated Go package earlier in the candidate list (e.g. the working directory) is skipped.
func isSettingsSourceDir(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "generator.go"))
if err != nil || info.IsDir() {
return false
}
info, err = os.Stat(filepath.Join(dir, "structs.go"))
return err == nil && !info.IsDir()
}
// identifySecretFieldsByReflection identifies secret fields by known field names
func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets SecretFieldsMap) {
if v.Kind() == reflect.Pointer {
@@ -1099,20 +1380,9 @@ func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets
secrets[typeName] = make(map[string]bool)
}
// Known secret field names
secretFields := map[string]bool{
"Key": true,
"AdminUsername": true,
"AdminPassword": true,
"TotpSecret": true,
"ClientID": true,
"ClientSecret": true,
"Secret": true,
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if secretFields[field.Name] {
if SecretFieldNames[field.Name] {
secrets[typeName][field.Name] = true
}
+18
View File
@@ -3,7 +3,9 @@ package settings
import (
"fmt"
"net"
"net/netip"
"strconv"
"strings"
)
// HTTPListenAddr returns a TCP listen address suitable for net.Listen.
@@ -20,5 +22,21 @@ func ValidateHttpConfig(h Http) error {
if h.Socket != "" && (h.TLSCert != "" || h.TLSKey != "") {
return fmt.Errorf("http.socket cannot be used with tlsCert or tlsKey")
}
for _, entry := range h.TrustedProxies {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if prefix, err := netip.ParsePrefix(entry); err != nil {
if _, addrErr := netip.ParseAddr(entry); addrErr != nil {
return fmt.Errorf("http.trustedProxies entry %q is not a valid IP address or CIDR range", entry)
}
} else if _, err := normalizeTrustedProxyPrefix(prefix); err != nil {
return fmt.Errorf("http.trustedProxies entry %q: %v", entry, err)
}
}
if _, err := parseSocketFileMode(h.SocketFileMode); err != nil {
return err
}
return nil
}
+318
View File
@@ -0,0 +1,318 @@
package settings
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// secretSentinelValues are deliberately unique so a surviving sentinel in the marshaled
// output is unambiguously a leak.
func setSecretSentinels(t *testing.T) {
t.Helper()
prev := Config
t.Cleanup(func() { Config = prev })
Config.Auth.Key = "SENTINEL-AUTH-KEY"
Config.Auth.TotpSecret = "SENTINEL-TOTP-SECRET"
Config.Auth.AdminUsername = "SENTINEL-LEGACY-ADMIN-USER"
Config.Auth.AdminPassword = "SENTINEL-LEGACY-ADMIN-PW"
Config.Auth.Methods.PasswordAuth.AdminUsername = "SENTINEL-ADMIN-USER"
Config.Auth.Methods.PasswordAuth.AdminPassword = "SENTINEL-ADMIN-PW"
Config.Auth.Methods.PasswordAuth.Recaptcha.Secret = "SENTINEL-RECAPTCHA-SECRET"
Config.Auth.Methods.OidcAuth.ClientID = "SENTINEL-OIDC-CLIENT-ID"
Config.Auth.Methods.OidcAuth.ClientSecret = "SENTINEL-OIDC-CLIENT-SECRET"
Config.Auth.Methods.LdapAuth.UserPassword = "SENTINEL-LDAP-PASSWORD"
Config.Auth.Methods.JwtAuth.Secret = "SENTINEL-JWT-SECRET"
Config.Integrations.OnlyOffice.Secret = "SENTINEL-OFFICE-SECRET"
}
// TestRedactedSettingsCopy_marshalsWithoutPlaintextSecrets is the regression guard for
// GHSA-892f-9h5p-mfc8: GET /api/settings returned cleartext secrets.
func TestRedactedSettingsCopy_marshalsWithoutPlaintextSecrets(t *testing.T) {
setSecretSentinels(t)
out, err := json.Marshal(RedactedSettingsCopy(&Config))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(out), "SENTINEL-") {
t.Fatalf("redacted settings still contain a plaintext secret: %s", out)
}
// The placeholder must be present so an operator can tell a secret is configured.
if !strings.Contains(string(out), redactPlaceholder) {
t.Fatalf("expected redaction placeholder %q in output", redactPlaceholder)
}
}
// TestRedactedSettingsCopy_doesNotMutateLiveConfig is the regression guard for the live-config
// mutation that broke JWT signing and the integrations after the first GET /api/settings.
func TestRedactedSettingsCopy_doesNotMutateLiveConfig(t *testing.T) {
setSecretSentinels(t)
_ = RedactedSettingsCopy(&Config)
if Config.Auth.Key != "SENTINEL-AUTH-KEY" {
t.Fatalf("live config auth key was mutated: %q", Config.Auth.Key)
}
if Config.Auth.TotpSecret != "SENTINEL-TOTP-SECRET" {
t.Fatalf("live config totp secret was mutated: %q", Config.Auth.TotpSecret)
}
if Config.Auth.Methods.PasswordAuth.Recaptcha.Secret != "SENTINEL-RECAPTCHA-SECRET" {
t.Fatalf("live recaptcha secret was mutated: %q", Config.Auth.Methods.PasswordAuth.Recaptcha.Secret)
}
if Config.Auth.Methods.LdapAuth.UserPassword != "SENTINEL-LDAP-PASSWORD" {
t.Fatalf("live LDAP password was mutated: %q", Config.Auth.Methods.LdapAuth.UserPassword)
}
// The deprecated top-level password must survive too, since redaction of the copy must not
// alias back into the source struct.
if Config.Auth.AdminPassword != "SENTINEL-LEGACY-ADMIN-PW" {
t.Fatalf("live legacy admin password was mutated: %q", Config.Auth.AdminPassword)
}
}
// TestRedactedSettingsCopy_isFaithfulDeepCopy proves the reflection copy reproduces the whole
// config graph (every field type) before redaction, so no field is silently dropped.
func TestRedactedSettingsCopy_isFaithfulDeepCopy(t *testing.T) {
prev := Config
t.Cleanup(func() { Config = prev })
Config = SetDefaults(false)
Config.Integrations.OnlyOffice.Secret = "" // keep secrets empty so redaction is a no-op
Config.Auth.Key = ""
Config.Auth.TotpSecret = ""
Config.Auth.AdminPassword = ""
Config.Auth.AdminUsername = ""
Config.Auth.Methods.PasswordAuth.AdminPassword = ""
Config.Auth.Methods.PasswordAuth.AdminUsername = ""
Config.Auth.Methods.OidcAuth.ClientID = ""
Config.Auth.Methods.OidcAuth.ClientSecret = ""
Config.Auth.Methods.LdapAuth.UserPassword = ""
Config.Auth.Methods.JwtAuth.Secret = ""
Config.Auth.Methods.PasswordAuth.Recaptcha.Secret = ""
Config.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false)
Config.Server.Sources = []*Source{{Path: "/srv", Name: "srv"}}
cp := RedactedSettingsCopy(&Config)
if cp == &Config {
t.Fatal("expected a new Settings, got the same pointer")
}
if !reflect.DeepEqual(cp, &Config) {
t.Fatal("deep copy does not match the source config")
}
}
// TestRedactedSettingsCopy_isDeepCopy verifies nested maps/slices are copied, not aliased, so a
// caller cannot corrupt the live config by mutating the returned copy.
func TestRedactedSettingsCopy_isDeepCopy(t *testing.T) {
prev := Config
t.Cleanup(func() { Config = prev })
Config = SetDefaults(false)
Config.Server.Sources = []*Source{{Path: "/srv", Name: "original"}}
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{"a": {Description: "original"}}
Config.Server.SourceMap = map[string]*Source{"/srv": {Path: "/srv", Name: "original"}}
cp := RedactedSettingsCopy(&Config)
cp.Server.Sources[0].Name = "changed"
cp.Server.SourceMap["/srv"].Name = "changed"
cp.Frontend.Styling.CustomThemes["a"] = CustomTheme{Description: "changed"}
if Config.Server.Sources[0].Name != "original" {
t.Fatalf("source slice aliased the live config: %q", Config.Server.Sources[0].Name)
}
if Config.Server.SourceMap["/srv"].Name != "original" {
t.Fatalf("source map aliased the live config: %q", Config.Server.SourceMap["/srv"].Name)
}
if Config.Frontend.Styling.CustomThemes["a"].Description != "original" {
t.Fatalf("custom themes map aliased the live config: %q", Config.Frontend.Styling.CustomThemes["a"].Description)
}
}
func TestRedactedSettingsCopy_preservesNonSecretValues(t *testing.T) {
setSecretSentinels(t)
Config.Frontend.Name = "SENTINEL-NOT-A-SECRET"
Config.Http.Port = 8123
Config.Server.SearchResultsLimit = 4242
out, err := json.Marshal(RedactedSettingsCopy(&Config))
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got Settings
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Frontend.Name != "SENTINEL-NOT-A-SECRET" {
t.Fatalf("frontend name was redacted: %q", got.Frontend.Name)
}
if got.Http.Port != 8123 {
t.Fatalf("http port was altered: %d", got.Http.Port)
}
if got.Server.SearchResultsLimit != 4242 {
t.Fatalf("search limit was altered: %d", got.Server.SearchResultsLimit)
}
// A non-secret field in a struct that also holds secrets must survive: the OIDC/recaptcha
// containers are the easiest place to over-redact.
if got.Auth.Methods.OidcAuth.ClientID != redactPlaceholder {
t.Fatalf("expected OIDC clientId redacted, got %q", got.Auth.Methods.OidcAuth.ClientID)
}
if got.Auth.Methods.PasswordAuth.Recaptcha.Host == redactPlaceholder {
t.Fatal("recaptcha host must not be redacted (it is a public URL)")
}
}
// TestRedactedSettingsCopy_emptySecretsStayEmpty ensures an unset secret does not appear to be set.
func TestRedactedSettingsCopy_emptySecretsStayEmpty(t *testing.T) {
prev := Config
t.Cleanup(func() { Config = prev })
Config = SetDefaults(false)
Config.Auth.Key = ""
Config.Auth.TotpSecret = ""
out, err := json.Marshal(RedactedSettingsCopy(&Config))
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got Settings
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Auth.Key != "" || got.Auth.TotpSecret != "" {
t.Fatalf("empty secrets should stay empty, got key=%q totp=%q", got.Auth.Key, got.Auth.TotpSecret)
}
}
// TestRedactAuthSecrets_doesNotMutateConfig verifies the by-value helper leaves the live config alone.
func TestRedactAuthSecrets_doesNotMutateConfig(t *testing.T) {
setSecretSentinels(t)
redacted := RedactAuthSecrets(Config.Auth)
if redacted.Key != redactPlaceholder {
t.Fatalf("expected redacted key, got %q", redacted.Key)
}
if Config.Auth.Key != "SENTINEL-AUTH-KEY" {
t.Fatalf("live config auth key was mutated: %q", Config.Auth.Key)
}
}
// TestSecretMarkers_drive_yaml_redaction guards the marker gaps fixed for GHSA-892f-9h5p-mfc8:
// the YAML export redacts recaptcha.secret and ldap.userPassword, which previously leaked
// because their Go comments lacked the `secret:` marker the generator looks for.
func TestSecretMarkers_drive_yaml_redaction(t *testing.T) {
embedded, err := os.ReadFile("../../../frontend/public/config.generated.yaml")
if err != nil {
t.Fatalf("read generated yaml: %v", err)
}
prev := Config
t.Cleanup(func() { Config = prev })
Config = SetDefaults(false)
Config.Auth.Key = "SENTINEL-AUTH-KEY"
Config.Auth.TotpSecret = "SENTINEL-TOTP"
Config.Auth.AdminPassword = "SENTINEL-LEGACY-ADMIN-PW"
Config.Auth.AdminUsername = "SENTINEL-LEGACY-ADMIN-USER"
Config.Auth.Methods.PasswordAuth.AdminPassword = "SENTINEL-NESTED-ADMIN-PW"
Config.Auth.Methods.PasswordAuth.Recaptcha.Secret = "SENTINEL-RECAPTCHA"
Config.Auth.Methods.OidcAuth.ClientSecret = "SENTINEL-OIDC"
Config.Auth.Methods.LdapAuth.UserPassword = "SENTINEL-LDAP"
Config.Auth.Methods.JwtAuth.Secret = "SENTINEL-JWT"
Config.Integrations.OnlyOffice.Secret = "SENTINEL-OFFICE"
out, err := GenerateConfigYamlWithEmbedded(&Config, false, true, false, string(embedded))
if err != nil {
t.Fatalf("generate yaml: %v", err)
}
for _, sentinel := range []string{
"SENTINEL-AUTH-KEY",
"SENTINEL-TOTP",
"SENTINEL-LEGACY-ADMIN-PW",
"SENTINEL-LEGACY-ADMIN-USER",
"SENTINEL-NESTED-ADMIN-PW",
"SENTINEL-RECAPTCHA",
"SENTINEL-OIDC",
"SENTINEL-LDAP",
"SENTINEL-JWT",
"SENTINEL-OFFICE",
} {
if strings.Contains(out, sentinel) {
t.Errorf("YAML export leaked %s", sentinel)
}
}
}
// TestTestSecretFieldNames_covers_yaml_marker_gaps guards the two fields whose Go comments lacked
// the "secret:" marker (recaptcha secret, LDAP bind password), plus the deprecated top-level
// auth fields that the filtered generated YAML omits entirely.
func TestSecretFieldNames_covers_yaml_marker_gaps(t *testing.T) {
secrets := ActiveSecretFields()
for _, tc := range []struct{ typ, field string }{
{"Auth", "Key"},
{"Auth", "TotpSecret"},
{"Auth", "AdminPassword"},
{"PasswordAuthConfig", "AdminPassword"},
{"Recaptcha", "Secret"},
{"OidcConfig", "ClientSecret"},
{"JwtAuthConfig", "Secret"},
{"LdapConfig", "UserPassword"},
{"OnlyOffice", "Secret"},
} {
if !secrets[tc.typ][tc.field] {
t.Errorf("%s.%s is not marked secret", tc.typ, tc.field)
}
}
}
// TestActiveSecretFields_fromEmbeddedYaml exercises the deployed-binary path, where the
// secret markers come from the embedded config.generated.yaml rather than the Go sources.
func TestActiveSecretFields_fromEmbeddedYaml(t *testing.T) {
data, err := os.ReadFile("../../../frontend/public/config.generated.yaml")
if err != nil {
t.Skipf("generated YAML not available: %v", err)
}
// Assert the YAML parser output directly: ActiveSecretFields also unions in the reflection
// fallback (SecretFieldNames), which would mask broken marker detection.
_, secrets, _, err := CollectCommentsFromEmbeddedYaml(string(data))
if err != nil {
t.Fatalf("parse embedded yaml: %v", err)
}
for _, tc := range []struct{ typ, field string }{
{"Auth", "Key"},
{"Recaptcha", "Secret"},
{"OidcConfig", "ClientSecret"},
{"LdapConfig", "UserPassword"},
{"OnlyOffice", "Secret"},
{"PasswordAuthConfig", "AdminPassword"},
} {
if !secrets[tc.typ][tc.field] {
t.Errorf("%s.%s is not marked secret via embedded YAML", tc.typ, tc.field)
}
}
}
func TestHasAnySecret_ignoresTypesWithoutMarkers(t *testing.T) {
if hasAnySecret(SecretFieldsMap{"Context": {}, "Other": {"Field": false}}) {
t.Fatal("per-type entries without secret markers must not count")
}
if !hasAnySecret(SecretFieldsMap{"Auth": {"Key": true}}) {
t.Fatal("a marked field must count")
}
}
func TestIsSettingsSourceDir_skipsUnrelatedPackage(t *testing.T) {
unrelated := t.TempDir()
if err := os.WriteFile(filepath.Join(unrelated, "http.go"), []byte("package web\n\n// Context x\ntype Context struct{ Token string }\n"), 0o600); err != nil {
t.Fatal(err)
}
if isSettingsSourceDir(unrelated) {
t.Fatal("unrelated package must not be treated as the settings sources")
}
if !isSettingsSourceDir(".") {
t.Fatal("the settings package directory must be recognized")
}
}
+19 -11
View File
@@ -25,17 +25,25 @@ type Settings struct {
}
type Http struct {
Socket string `json:"socket"` // socket to listen on - eg. /var/run/filebrowser.sock
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
Port int `json:"port"` // port to listen on
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
DisableWebDAV bool `json:"disableWebDAV"` // disable webdav support (default: false)
TrustProxyHeaders bool `json:"trustProxyHeaders"` // honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)
DisableRateLimit bool `json:"disableRateLimit"` // turns off built-in auth route rate limiting and failed-login lockout (default false).
Socket string `json:"socket"` // socket to listen on - eg. /var/run/filebrowser.sock
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
Port int `json:"port"` // port to listen on
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
DisableWebDAV bool `json:"disableWebDAV"` // disable webdav support (default: false)
TrustProxyHeaders bool `json:"trustProxyHeaders"` // honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)
TrustedProxies []string `json:"trustedProxies"` // IPs/CIDRs of trusted proxies; forwarded client headers are only honored when the socket peer matches one of these (default: empty = forwarded headers ignored)
// UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
// present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
// are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
UnixSocketTrustedUIDs []int `json:"unixSocketTrustedUIDs"`
UnixSocketTrustedGIDs []int `json:"unixSocketTrustedGIDs"`
SocketFileMode string `json:"socketFileMode"` // octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
SocketGroup string `json:"socketGroup"` // optional group name or numeric gid for the socket file (filesystem sockets only)
DisableRateLimit bool `json:"disableRateLimit"` // turns off built-in auth route rate limiting and failed-login lockout (default false).
}
type Environment struct {
+196
View File
@@ -0,0 +1,196 @@
package settings
import (
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"sync"
)
const ipv4MappedPrefixBits = 96
// normalizeTrustedProxyPrefix converts IPv4-mapped IPv6 prefixes to IPv4 and
// masks the result. Mapped prefixes must include the full ::ffff:0:0/96 anchor.
func normalizeTrustedProxyPrefix(prefix netip.Prefix) (netip.Prefix, error) {
if prefix.Addr().Is4In6() {
if prefix.Bits() < ipv4MappedPrefixBits {
return netip.Prefix{}, fmt.Errorf("IPv4-mapped prefix must be at least /%d", ipv4MappedPrefixBits)
}
v4Bits := prefix.Bits() - ipv4MappedPrefixBits
if v4Bits > 32 {
v4Bits = 32
}
return netip.PrefixFrom(prefix.Addr().Unmap(), v4Bits).Masked(), nil
}
return prefix.Masked(), nil
}
var (
trustedProxiesMu sync.Mutex
trustedProxiesKey string
trustedProxiesCache []netip.Prefix
)
// TrustedProxyPrefixes parses Config.Http.TrustedProxies into CIDR prefixes,
// caching the result until the configured list changes. Entries may be CIDR
// ranges or bare IPs; unparsable entries are skipped (ValidateHttpConfig
// rejects them at startup).
func TrustedProxyPrefixes() []netip.Prefix {
key := strings.Join(Config.Http.TrustedProxies, "\x1e")
trustedProxiesMu.Lock()
defer trustedProxiesMu.Unlock()
if key == trustedProxiesKey {
return trustedProxiesCache
}
var prefixes []netip.Prefix
for _, entry := range Config.Http.TrustedProxies {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if prefix, err := netip.ParsePrefix(entry); err == nil {
normalized, normErr := normalizeTrustedProxyPrefix(prefix)
if normErr != nil {
continue
}
prefixes = append(prefixes, normalized)
continue
}
if addr, err := netip.ParseAddr(entry); err == nil {
if addr.Is4In6() {
addr = addr.Unmap()
}
prefixes = append(prefixes, netip.PrefixFrom(addr, addr.BitLen()))
}
}
trustedProxiesKey = key
trustedProxiesCache = prefixes
return prefixes
}
// parseHopAddr parses an IP (optionally with a port, as in host:port) into a
// canonical address. IPv4-mapped IPv6 addresses are unmapped to plain IPv4 so
// they compare correctly against IPv4 prefixes.
func parseHopAddr(hop string) (netip.Addr, bool) {
hop = strings.TrimSpace(hop)
addr, err := netip.ParseAddr(hop)
if err != nil {
host, _, splitErr := net.SplitHostPort(hop)
if splitErr != nil {
return netip.Addr{}, false
}
addr, err = netip.ParseAddr(host)
if err != nil {
return netip.Addr{}, false
}
}
return addr.Unmap(), true
}
// addrMatchesPrefixes reports whether addr falls inside any of the prefixes.
func addrMatchesPrefixes(addr netip.Addr, prefixes []netip.Prefix) bool {
for _, prefix := range prefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
// ipMatchesPrefixes reports whether ip falls inside any of the given prefixes.
// Entries that are not valid IPs (optionally with a port) never match.
func ipMatchesPrefixes(ip string, prefixes []netip.Prefix) bool {
addr, ok := parseHopAddr(ip)
if !ok {
return false
}
return addrMatchesPrefixes(addr, prefixes)
}
// IsTrustedProxyPeer reports whether the HTTP connection peer may present trusted
// proxy headers. Forwarded client headers must only be honored when this returns
// true; otherwise any client that can reach the listener directly could spoof its address.
//
// When http.socket is set the server listens only on that Unix socket. Filesystem
// socket peers are trusted only when their UID or GID matches
// http.unixSocketTrustedUIDs / http.unixSocketTrustedGIDs (Linux peer credentials).
// Abstract sockets ("@name") have no filesystem permissions and their peers are never trusted.
func IsTrustedProxyPeer(r *http.Request) bool {
if r == nil {
return false
}
if socket := Config.Http.Socket; socket != "" {
if strings.HasPrefix(socket, "@") {
return false
}
cred, ok := UnixPeerFromContext(r.Context())
if !ok {
return false
}
return unixPeerAllowed(cred.UID, cred.GID)
}
return ipMatchesPrefixes(r.RemoteAddr, TrustedProxyPrefixes())
}
// canonicalHopIP returns the bare, unmapped IP for a forwarded hop, dropping
// any source port. It returns "" when the hop is not a valid IP.
func canonicalHopIP(hop string) string {
addr, ok := parseHopAddr(hop)
if !ok {
return ""
}
return addr.String()
}
// TrustedProxyClientIP resolves the originating client IP from an
// X-Forwarded-For header value.
//
// When http.trustedProxies is empty, the rightmost entry is returned: that is
// the peer recorded by the single trusted reverse proxy. Over TCP, callers only
// reach this after IsTrustedProxyPeer, which needs a configured prefix, so the
// empty case applies to Unix-socket listeners (http.socket) and direct callers.
//
// When trustedProxies is configured, the chain is walked right-to-left past
// every entry that belongs to a trusted proxy; the first untrusted entry is
// the client. This keeps client identity correct in multi-hop deployments
// (e.g. CDN -> reverse proxy -> filebrowser), where the rightmost entry would
// otherwise be the edge proxy shared by all clients.
func TrustedProxyClientIP(xff string) string {
parts := strings.Split(xff, ",")
last := -1
for i := len(parts) - 1; i >= 0; i-- {
if strings.TrimSpace(parts[i]) != "" {
last = i
break
}
}
if last < 0 {
return ""
}
prefixes := TrustedProxyPrefixes()
if len(prefixes) == 0 {
return canonicalHopIP(parts[last])
}
for i := last; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip == "" {
continue
}
addr, ok := parseHopAddr(ip)
if !ok {
return ""
}
if !addrMatchesPrefixes(addr, prefixes) {
return addr.String()
}
}
// Every entry is a trusted proxy; the leftmost is closest to the client.
for _, part := range parts {
if ip := strings.TrimSpace(part); ip != "" {
return canonicalHopIP(ip)
}
}
return ""
}
@@ -0,0 +1,184 @@
package settings
import (
"context"
"net/http"
"net/netip"
"testing"
)
func resetTrustedProxyCache() {
trustedProxiesMu.Lock()
trustedProxiesKey = ""
trustedProxiesCache = nil
trustedProxiesMu.Unlock()
}
func TestNormalizeTrustedProxyPrefixIPv4Mapped(t *testing.T) {
prefix, err := netip.ParsePrefix("::ffff:198.51.100.0/120")
if err != nil {
t.Fatal(err)
}
got, err := normalizeTrustedProxyPrefix(prefix)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if got.Addr().String() != "198.51.100.0" || got.Bits() != 24 {
t.Fatalf("got %v, want 198.51.100.0/24", got)
}
}
func TestNormalizeTrustedProxyPrefixRejectsShortMapped(t *testing.T) {
prefix, err := netip.ParsePrefix("::ffff:198.51.100.0/80")
if err != nil {
t.Fatal(err)
}
_, err = normalizeTrustedProxyPrefix(prefix)
if err == nil {
t.Fatal("expected error for mapped prefix shorter than /96")
}
}
func TestTrustedProxyPrefixesMatchesPlainIPv4Client(t *testing.T) {
resetTrustedProxyCache()
Config.Http.TrustedProxies = []string{"::ffff:198.51.100.0/120"}
t.Cleanup(func() {
Config.Http.TrustedProxies = nil
resetTrustedProxyCache()
})
prefixes := TrustedProxyPrefixes()
if len(prefixes) != 1 {
t.Fatalf("prefixes len = %d, want 1", len(prefixes))
}
if !ipMatchesPrefixes("198.51.100.7", prefixes) {
t.Fatal("plain IPv4 client should match normalized mapped prefix")
}
}
func TestTrustedProxyClientIPRejectsMalformedHop(t *testing.T) {
resetTrustedProxyCache()
Config.Http.TrustedProxies = []string{"198.51.100.7"}
t.Cleanup(func() {
Config.Http.TrustedProxies = nil
resetTrustedProxyCache()
})
got := TrustedProxyClientIP("203.0.113.50, not-an-ip, 198.51.100.7")
if got != "" {
t.Fatalf("malformed hop: got %q, want empty string", got)
}
}
func TestIPMatchesPrefixesUnmapsMappedHop(t *testing.T) {
resetTrustedProxyCache()
Config.Http.TrustedProxies = []string{"198.51.100.0/24"}
t.Cleanup(func() {
Config.Http.TrustedProxies = nil
resetTrustedProxyCache()
})
if !ipMatchesPrefixes("::ffff:198.51.100.7", TrustedProxyPrefixes()) {
t.Fatal("IPv4-mapped hop should match plain IPv4 prefix")
}
}
func TestTrustedProxyClientIPStripsHopPorts(t *testing.T) {
resetTrustedProxyCache()
Config.Http.TrustedProxies = []string{"198.51.100.0/24"}
t.Cleanup(func() {
Config.Http.TrustedProxies = nil
resetTrustedProxyCache()
})
got := TrustedProxyClientIP("203.0.113.50:40000, 198.51.100.7:5555")
if got != "203.0.113.50" {
t.Fatalf("got %q, want 203.0.113.50 without port", got)
}
}
func TestTrustedProxyClientIPCanonicalizesWithoutPrefixes(t *testing.T) {
resetTrustedProxyCache()
Config.Http.TrustedProxies = nil
t.Cleanup(resetTrustedProxyCache)
if got := TrustedProxyClientIP("203.0.113.50, 198.51.100.7:5555"); got != "198.51.100.7" {
t.Fatalf("got %q, want 198.51.100.7", got)
}
}
func TestIsTrustedProxyPeer(t *testing.T) {
resetTrustedProxyCache()
Config.Http.TrustedProxies = nil
t.Cleanup(func() {
Config.Http.TrustedProxies = nil
resetTrustedProxyCache()
})
if IsTrustedProxyPeer(&http.Request{RemoteAddr: "10.0.0.1:1234"}) {
t.Fatal("no trusted proxies configured: peer must not be trusted")
}
Config.Http.TrustedProxies = []string{"10.0.0.0/8"}
resetTrustedProxyCache()
if !IsTrustedProxyPeer(&http.Request{RemoteAddr: "10.0.0.1:1234"}) {
t.Fatal("peer in configured range should be trusted")
}
if !IsTrustedProxyPeer(&http.Request{RemoteAddr: "10.0.0.1"}) {
t.Fatal("bare IP peer in configured range should be trusted")
}
if IsTrustedProxyPeer(&http.Request{RemoteAddr: "192.0.2.1:1234"}) {
t.Fatal("peer outside configured range must not be trusted")
}
}
// With http.socket set, forwarded headers require matching unixSocketTrustedUIDs/GIDs.
func TestIsTrustedProxyPeerUnixSocket(t *testing.T) {
resetTrustedProxyCache()
Config.Http.TrustedProxies = nil
Config.Http.Socket = "/tmp/filebrowser.sock"
t.Cleanup(func() {
Config.Http.Socket = ""
Config.Http.UnixSocketTrustedUIDs = nil
Config.Http.UnixSocketTrustedGIDs = nil
resetTrustedProxyCache()
})
for _, peer := range []string{"", "@"} {
if IsTrustedProxyPeer(&http.Request{RemoteAddr: peer}) {
t.Fatalf("unix socket peer %q must not be trusted without peer credentials", peer)
}
}
Config.Http.UnixSocketTrustedUIDs = []int{33}
req := (&http.Request{}).WithContext(ContextWithUnixPeerForTest(context.Background(), 33, 1000))
if !IsTrustedProxyPeer(req) {
t.Fatal("peer with allowed UID should be trusted")
}
reqBad := (&http.Request{}).WithContext(ContextWithUnixPeerForTest(context.Background(), 34, 1000))
if IsTrustedProxyPeer(reqBad) {
t.Fatal("peer with disallowed UID must not be trusted")
}
if got := TrustedProxyClientIP("203.0.113.50"); got != "203.0.113.50" {
t.Fatalf("got %q, want 203.0.113.50", got)
}
}
func TestIsTrustedProxyPeerAbstractSocketNotTrusted(t *testing.T) {
resetTrustedProxyCache()
Config.Http.TrustedProxies = nil
Config.Http.Socket = "@filebrowser"
t.Cleanup(func() {
Config.Http.Socket = ""
resetTrustedProxyCache()
})
if IsTrustedProxyPeer(&http.Request{RemoteAddr: "@"}) {
t.Fatal("abstract socket peers must not be trusted")
}
}
func TestValidateHttpConfigRejectsShortMappedPrefix(t *testing.T) {
err := ValidateHttpConfig(Http{TrustedProxies: []string{"::ffff:198.51.100.0/80"}})
if err == nil {
t.Fatal("expected validation error for short IPv4-mapped prefix")
}
}
+37
View File
@@ -0,0 +1,37 @@
//go:build linux
package settings
import (
"context"
"net"
"golang.org/x/sys/unix"
)
func peerCredentialFromConn(c net.Conn) (UnixPeerCredential, bool) {
uc, ok := c.(*net.UnixConn)
if !ok {
return UnixPeerCredential{}, false
}
raw, err := uc.SyscallConn()
if err != nil {
return UnixPeerCredential{}, false
}
var ucred *unix.Ucred
var ctlErr error
ctlErr = raw.Control(func(fd uintptr) {
ucred, ctlErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
})
if ctlErr != nil || ucred == nil {
return UnixPeerCredential{}, false
}
return UnixPeerCredential{UID: int(ucred.Uid), GID: int(ucred.Gid), Ok: true}, true
}
func attachUnixPeerContext(ctx context.Context, c net.Conn) context.Context {
if cred, ok := peerCredentialFromConn(c); ok {
ctx = context.WithValue(ctx, unixPeerCredKey{}, cred)
}
return ctx
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !linux
package settings
import (
"context"
"net"
)
func peerCredentialFromConn(net.Conn) (UnixPeerCredential, bool) {
return UnixPeerCredential{}, false
}
func attachUnixPeerContext(ctx context.Context, _ net.Conn) context.Context {
return ctx
}
+126
View File
@@ -0,0 +1,126 @@
package settings
import (
"context"
"fmt"
"net"
"os"
"os/user"
"strconv"
"strings"
)
type unixPeerCredKey struct{}
// UnixPeerCredential holds the peer process identity for a Unix domain connection.
type UnixPeerCredential struct {
UID int
GID int
Ok bool
}
// AttachConnContext stores Unix peer credentials on the request context (Linux SO_PEERCRED).
func AttachConnContext(ctx context.Context, c net.Conn) context.Context {
return attachUnixPeerContext(ctx, c)
}
// UnixPeerFromContext returns peer credentials attached by AttachConnContext.
func UnixPeerFromContext(ctx context.Context) (UnixPeerCredential, bool) {
cred, ok := ctx.Value(unixPeerCredKey{}).(UnixPeerCredential)
return cred, ok && cred.Ok
}
// ContextWithUnixPeerForTest attaches synthetic peer credentials (tests only).
func ContextWithUnixPeerForTest(ctx context.Context, uid, gid int) context.Context {
return context.WithValue(ctx, unixPeerCredKey{}, UnixPeerCredential{UID: uid, GID: gid, Ok: true})
}
func parseSocketFileMode(mode string) (os.FileMode, error) {
mode = strings.TrimSpace(mode)
if mode == "" {
return 0o660, nil
}
v, err := strconv.ParseUint(mode, 8, 32)
if err != nil {
return 0, fmt.Errorf("invalid http.socketFileMode %q", mode)
}
if v&0o777 != v {
return 0, fmt.Errorf("http.socketFileMode must be a 3-digit octal mode")
}
return os.FileMode(v), nil
}
// ListenUnixFilesystem creates a filesystem Unix socket listener with restrictive permissions.
// Abstract sockets (path prefixed with @) skip chmod/chown.
func ListenUnixFilesystem(socketPath string) (net.Listener, error) {
if strings.HasPrefix(socketPath, "@") {
return net.Listen("unix", socketPath)
}
mode, err := parseSocketFileMode(Config.Http.SocketFileMode)
if err != nil {
return nil, err
}
var gid int
setGroup := false
if group := strings.TrimSpace(Config.Http.SocketGroup); group != "" {
gid, err = resolveGroupID(group)
if err != nil {
return nil, err
}
setGroup = true
}
oldMask := unixUmask(0o077)
defer unixUmask(oldMask)
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, err
}
closeListener := func() { _ = listener.Close() }
// Assign group ownership while the socket is still owner-only (umask 077), then grant mode bits.
if setGroup {
if err := os.Chown(socketPath, -1, gid); err != nil {
closeListener()
return nil, err
}
}
if err := os.Chmod(socketPath, mode); err != nil {
closeListener()
return nil, err
}
return listener, nil
}
func resolveGroupID(nameOrID string) (int, error) {
if id, err := strconv.Atoi(nameOrID); err == nil {
return id, nil
}
// Lookup group by name without importing os/user if possible - use user.LookupGroup
g, err := user.LookupGroup(nameOrID)
if err != nil {
return 0, fmt.Errorf("http.socketGroup: %w", err)
}
id, err := strconv.Atoi(g.Gid)
if err != nil {
return 0, fmt.Errorf("http.socketGroup: %w", err)
}
return id, nil
}
func unixPeerAllowed(uid, gid int) bool {
uids := Config.Http.UnixSocketTrustedUIDs
gids := Config.Http.UnixSocketTrustedGIDs
if len(uids) == 0 && len(gids) == 0 {
return false
}
for _, u := range uids {
if u == uid {
return true
}
}
for _, g := range gids {
if g == gid {
return true
}
}
return false
}
+53
View File
@@ -0,0 +1,53 @@
package settings
import "testing"
func TestParseSocketFileModeDefaults(t *testing.T) {
mode, err := parseSocketFileMode("")
if err != nil {
t.Fatal(err)
}
if mode != 0o660 {
t.Fatalf("got %o, want 0660", mode)
}
}
func TestParseSocketFileModeCustom(t *testing.T) {
mode, err := parseSocketFileMode("0600")
if err != nil {
t.Fatal(err)
}
if mode != 0o600 {
t.Fatalf("got %o, want 0600", mode)
}
}
func TestValidateHttpConfigRejectsBadSocketFileMode(t *testing.T) {
err := ValidateHttpConfig(Http{SocketFileMode: "not-octal"})
if err == nil {
t.Fatal("expected validation error for socketFileMode")
}
}
func TestUnixPeerAllowed(t *testing.T) {
Config.Http.UnixSocketTrustedUIDs = []int{10}
Config.Http.UnixSocketTrustedGIDs = []int{20}
t.Cleanup(func() {
Config.Http.UnixSocketTrustedUIDs = nil
Config.Http.UnixSocketTrustedGIDs = nil
})
if !unixPeerAllowed(10, 999) {
t.Fatal("expected UID match")
}
if !unixPeerAllowed(999, 20) {
t.Fatal("expected GID match")
}
if unixPeerAllowed(1, 1) {
t.Fatal("expected no match")
}
Config.Http.UnixSocketTrustedUIDs = nil
Config.Http.UnixSocketTrustedGIDs = nil
if unixPeerAllowed(0, 0) {
t.Fatal("empty allowlists must deny")
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !unix && !windows
package settings
func unixUmask(_ int) int {
return 0
}
+9
View File
@@ -0,0 +1,9 @@
//go:build unix
package settings
import "syscall"
func unixUmask(mask int) int {
return syscall.Umask(mask)
}
@@ -0,0 +1,7 @@
//go:build windows
package settings
func unixUmask(_ int) int {
return 0
}
+34 -2
View File
@@ -6013,6 +6013,14 @@ const docTemplate = `{
"description": "socket to listen on - eg. /var/run/filebrowser.sock",
"type": "string"
},
"socketFileMode": {
"description": "octal mode for filesystem http.socket (default \"0660\"); ignored for abstract sockets",
"type": "string"
},
"socketGroup": {
"description": "optional group name or numeric gid for the socket file (filesystem sockets only)",
"type": "string"
},
"tlsCert": {
"description": "path to TLS cert",
"type": "string"
@@ -6024,6 +6032,26 @@ const docTemplate = `{
"trustProxyHeaders": {
"description": "honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)",
"type": "boolean"
},
"trustedProxies": {
"description": "IPs/CIDRs of trusted proxies; forwarded client headers are only honored when the socket peer matches one of these (default: empty = forwarded headers ignored)",
"type": "array",
"items": {
"type": "string"
}
},
"unixSocketTrustedGIDs": {
"type": "array",
"items": {
"type": "integer"
}
},
"unixSocketTrustedUIDs": {
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers\nare not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.",
"type": "array",
"items": {
"type": "integer"
}
}
}
},
@@ -6175,7 +6203,7 @@ const docTemplate = `{
"type": "string"
},
"userPassword": {
"description": "Bind password for service account",
"description": "secret: bind password for the LDAP service account",
"type": "string"
}
}
@@ -6488,7 +6516,7 @@ const docTemplate = `{
"type": "string"
},
"secret": {
"description": "v2 secret key",
"description": "secret: v2 secret key",
"type": "string"
}
}
@@ -7635,6 +7663,10 @@ const docTemplate = `{
"description": "for backward compatibility",
"type": "string"
},
"minimal": {
"description": "Minimal distinguishes owner-inheriting tokens from customized caps. Nil means legacy (infer from stored permissions).",
"type": "boolean"
},
"name": {
"type": "string"
},
+34 -2
View File
@@ -6002,6 +6002,14 @@
"description": "socket to listen on - eg. /var/run/filebrowser.sock",
"type": "string"
},
"socketFileMode": {
"description": "octal mode for filesystem http.socket (default \"0660\"); ignored for abstract sockets",
"type": "string"
},
"socketGroup": {
"description": "optional group name or numeric gid for the socket file (filesystem sockets only)",
"type": "string"
},
"tlsCert": {
"description": "path to TLS cert",
"type": "string"
@@ -6013,6 +6021,26 @@
"trustProxyHeaders": {
"description": "honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)",
"type": "boolean"
},
"trustedProxies": {
"description": "IPs/CIDRs of trusted proxies; forwarded client headers are only honored when the socket peer matches one of these (default: empty = forwarded headers ignored)",
"type": "array",
"items": {
"type": "string"
}
},
"unixSocketTrustedGIDs": {
"type": "array",
"items": {
"type": "integer"
}
},
"unixSocketTrustedUIDs": {
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers\nare not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.",
"type": "array",
"items": {
"type": "integer"
}
}
}
},
@@ -6164,7 +6192,7 @@
"type": "string"
},
"userPassword": {
"description": "Bind password for service account",
"description": "secret: bind password for the LDAP service account",
"type": "string"
}
}
@@ -6477,7 +6505,7 @@
"type": "string"
},
"secret": {
"description": "v2 secret key",
"description": "secret: v2 secret key",
"type": "string"
}
}
@@ -7624,6 +7652,10 @@
"description": "for backward compatibility",
"type": "string"
},
"minimal": {
"description": "Minimal distinguishes owner-inheriting tokens from customized caps. Nil means legacy (infer from stored permissions).",
"type": "boolean"
},
"name": {
"type": "string"
},
+33 -2
View File
@@ -475,6 +475,14 @@ definitions:
socket:
description: socket to listen on - eg. /var/run/filebrowser.sock
type: string
socketFileMode:
description: octal mode for filesystem http.socket (default "0660"); ignored
for abstract sockets
type: string
socketGroup:
description: optional group name or numeric gid for the socket file (filesystem
sockets only)
type: string
tlsCert:
description: path to TLS cert
type: string
@@ -485,6 +493,25 @@ definitions:
description: 'honor X-Forwarded-* and X-Real-IP from a reverse proxy (default:
false)'
type: boolean
trustedProxies:
description: 'IPs/CIDRs of trusted proxies; forwarded client headers are only
honored when the socket peer matches one of these (default: empty = forwarded
headers ignored)'
items:
type: string
type: array
unixSocketTrustedGIDs:
items:
type: integer
type: array
unixSocketTrustedUIDs:
description: |-
UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
items:
type: integer
type: array
type: object
settings.IndexSqlConfig:
properties:
@@ -609,7 +636,7 @@ definitions:
"phone"
type: string
userPassword:
description: Bind password for service account
description: 'secret: bind password for the LDAP service account'
type: string
type: object
settings.LogConfig:
@@ -845,7 +872,7 @@ definitions:
description: v2 site key
type: string
secret:
description: v2 secret key
description: 'secret: v2 secret key'
type: string
required:
- host
@@ -1658,6 +1685,10 @@ definitions:
key:
description: for backward compatibility
type: string
minimal:
description: Minimal distinguishes owner-inheriting tokens from customized
caps. Nil means legacy (infer from stored permissions).
type: boolean
name:
type: string
token:
+2 -1
View File
@@ -47,7 +47,8 @@
"useGenericFontNames": "off",
"useKeyWithClickEvents": "off",
"useMediaCaption": "off",
"useSemanticElements": "off"
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"complexity": {
"noForEach": "on",
+9 -2
View File
@@ -93,7 +93,7 @@ auth:
recaptcha: # recaptcha config. If configured will show up the checkbox verification in the login page validate:omitempty
host: "" # google recaptcha host, for example: https://www.google.com/recaptcha/api.js validate:required
key: "" # v2 site key validate:required
secret: "" # v2 secret key validate:required
secret: "" # secret: v2 secret key validate:required
enforcedOtp: false # if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
oidc: # validate:omitempty
enabled: false # whether to enable this authentication method.
@@ -118,7 +118,7 @@ auth:
server: "" # scheme://host:port of the LDAP server (e.g. ldap://localhost:389)
baseDN: "" # LDAP search base DN (e.g. dc=ldap,dc=goauthentik,dc=io)
userDN: "" # Bind DN for service account (e.g. cn=admin,ou=users,dc=ldap,dc=goauthentik,dc=io)
userPassword: "" # Bind password for service account
userPassword: "" # secret: bind password for the LDAP service account
userFilter: "" # Search filter for finding user by username. Default (&(cn=%s)(objectClass=user)); override e.g. (email=%s) or (sAMAccountName=%s) for other directories.
jwt: # validate:omitempty
enabled: false # whether to enable this authentication method.
@@ -314,4 +314,11 @@ http:
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
disableWebDAV: false # disable webdav support (default: false)
trustProxyHeaders: false # honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)
trustedProxies: # IPs/CIDRs of trusted proxies; forwarded client headers are only honored when the socket peer matches one of these (default: empty = forwarded headers ignored)
unixSocketTrustedUIDs: # UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
# present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
# are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
unixSocketTrustedGIDs:
socketFileMode: "" # octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
socketGroup: "" # optional group name or numeric gid for the socket file (filesystem sockets only)
disableRateLimit: false # turns off built-in auth route rate limiting and failed-login lockout (default false).
+3 -1
View File
@@ -84,7 +84,9 @@ export async function search(base, sources, query, largest = false, extraParams
return data;
} catch (err) {
notify.showError(err.message || "Error occurred during search");
if (!extraParams.suppressNotify) {
notify.showError(err.message || "Error occurred during search");
}
throw err;
}
}
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { store } = vi.hoisted(() => ({ store: { tool: null, showSelectMultiple: false, mobile: false } }));
vi.mock("@/store", () => ({
state: {
get user() {
return { showSelectMultiple: store.showSelectMultiple };
},
},
mutations: {},
getters: {
currentTool: () => store.tool,
isMobile: () => store.mobile,
},
}));
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
vi.mock("@/components/Action.vue", () => ({ default: { render: () => null } }));
vi.mock("@/notify", () => ({ notify: {} }));
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
vi.mock("@/utils", () => ({ url: {} }));
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: vi.fn() }));
vi.mock("@/utils/constants.js", () => ({ globalVars: {} }));
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
vi.mock("@/utils/nativeShare", () => ({ canNativeShare: vi.fn(), nativeShareFile: vi.fn() }));
vi.mock("@/utils/mimetype", () => ({ isRichTextPreviewMimeType: vi.fn(), getTypeInfoFromExt: vi.fn() }));
vi.mock("@/utils/mediaFile", () => ({ isMediaRequest: vi.fn() }));
vi.mock("@/utils/expandTransition", () => ({ expandBeforeEnter: vi.fn(), expandEnter: vi.fn(), expandLeave: vi.fn() }));
import ContextMenu from "./ContextMenu.vue";
const { computed } = ContextMenu;
const inAdvancedSearch = () => computed.isAdvancedSearch.call({});
describe("ContextMenu in advanced search", () => {
beforeEach(() => {
store.tool = null;
store.showSelectMultiple = false;
store.mobile = false;
});
it("detects the advanced search tool", () => {
expect(inAdvancedSearch()).toBe(false);
store.tool = { component: "DuplicateFinder" };
expect(inAdvancedSearch()).toBe(false);
store.tool = { component: "AdvancedSearch" };
expect(inAdvancedSearch()).toBe(true);
});
it("offers Select multiple on desktop even when the user preference is off", () => {
const menu = (isAdvancedSearch) => ({ showLimitedOptions: false, isMultiple: false, isSearchActive: false, isAdvancedSearch });
expect(computed.showSelectMultiple.call(menu(false))).toBe(false);
expect(computed.showSelectMultiple.call(menu(true))).toBe(true);
});
it("doesn't offer Select multiple once multiple mode is on", () => {
expect(
computed.showSelectMultiple.call({ showLimitedOptions: false, isMultiple: true, isSearchActive: false, isAdvancedSearch: true }),
).toBe(false);
});
it("hides the create (+) toggle, since results have no single folder to create in", () => {
const toggle = (isAdvancedSearch) =>
computed.showCreateToggle.call({ showLimitedOptions: false, createOnly: false, isSearchActive: false, isAdvancedSearch });
expect(toggle(false)).toBe(true);
expect(toggle(true)).toBe(false);
});
it("hides Share when nothing is selected, since the results listing has no folder to share", () => {
const share = (isAdvancedSearch, selectedCount) =>
computed.showShareAction.call({ showLimitedOptions: false, isAdvancedSearch, selectedCount, showShare: true });
expect(share(true, 0)).toBe(false);
expect(share(true, 1)).toBe(true);
expect(share(true, 2)).toBe(false);
expect(share(false, 0)).toBe(true);
});
});
+14 -2
View File
@@ -378,6 +378,8 @@ export default {
showCreateFileActions() {
if (this.showLimitedOptions) return false;
if (!this.permissions.create) return false;
// Advanced search results are not a target folder; never offer create actions there.
if (this.isAdvancedSearch) return false;
return this.showCreate && !this.isSearchActive;
},
req() {
@@ -421,6 +423,8 @@ export default {
},
showShareAction() {
if (this.showLimitedOptions) return false;
// Advanced search results have no folder of their own to share, only the selected results do.
if (this.isAdvancedSearch && this.selectedCount === 0) return false;
return this.selectedCount <= 1 && this.showShare;
},
showSendToApp() {
@@ -483,7 +487,8 @@ export default {
},
/** + control in listing / share directory context; disabled when the panel cannot be opened. */
showCreateToggle() {
if (this.showLimitedOptions || this.createOnly) return false;
// Advanced search lists results from several folders, so there is no folder to create in.
if (this.showLimitedOptions || this.createOnly || this.isAdvancedSearch) return false;
return !this.isSearchActive;
},
createToggleDisabled() {
@@ -498,6 +503,10 @@ export default {
if (this.isMultiple || this.isSearchActive) {
return false;
}
// With "open with a single click" a plain click opens the item, so this is the way to select single results.
if (this.isAdvancedSearch) {
return true;
}
if (state.user?.showSelectMultiple) {
return true;
}
@@ -599,6 +608,9 @@ export default {
onlyofficeEnabled() {
return globalVars.onlyOfficeUrl !== "";
},
isAdvancedSearch() {
return getters.currentTool()?.component === "AdvancedSearch";
},
isSearchActive() {
return state.isSearchActive;
},
@@ -812,7 +824,7 @@ export default {
},
initializeCreateState() {
this.showNewFileTemplate = false;
if (this.createOnly) {
if (this.createOnly && !this.isAdvancedSearch) {
this.showCreate = true;
return;
}
@@ -0,0 +1,21 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, h } from "vue";
vi.mock("@/store", () => ({ state: { sources: {} }, mutations: { showPrompt: vi.fn() } }));
vi.mock("@/store/eventBus", () => ({ eventBus: { on: vi.fn(), off: vi.fn() } }));
import PathPickerButton from "./PathPickerButton.vue";
describe("PathPickerButton keyboard access", () => {
let app;
afterEach(() => app?.unmount());
it("can be focused and is exposed as a button, so its Enter and Space handlers can run", () => {
const el = document.createElement("div");
app = createApp({ render: () => h(PathPickerButton, { path: "/", source: "" }) });
app.config.globalProperties.$t = (key) => key;
app.mount(el);
const picker = el.querySelector(".unified-path-picker");
expect(picker.getAttribute("tabindex")).toBe("0");
expect(picker.getAttribute("role")).toBe("button");
});
});
@@ -1,6 +1,8 @@
<template>
<div
:aria-label="resolvedAriaLabel"
tabindex="0"
role="button"
class="searchContext clickable button unified-path-picker"
@click="openPicker"
@keydown.enter.prevent="openPicker"
@@ -170,4 +172,10 @@ export default {
overflow-wrap: break-word;
box-sizing: border-box;
}
/* .button removes the outline, so keyboard focus needs its own indicator. */
.unified-path-picker:focus-visible {
outline: 2px solid var(--primaryColor);
outline-offset: 2px;
}
</style>
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, h } from "vue";
const { mockState } = vi.hoisted(() => ({ mockState: {} }));
vi.mock("@/store", () => ({ state: mockState, getters: { getTime: () => "" } }));
vi.mock("@/api", () => ({ resourcesApi: {} }));
vi.mock("qrcode.vue", () => ({ default: { render: () => null } }));
import FilesShareInfo from "./ShareInfo.vue";
import PromptShareInfo from "../prompts/ShareInfo.vue";
// Shape returned by GET /public/api/share/info: `bannerUrl` is set, `banner` is not.
const publicShareInfo = {
hash: "abc",
shareType: "normal",
title: "Shared folder",
bannerUrl: "https://example.com/logo.svg",
shareURL: "https://example.com/public/share/abc",
};
function render(component, props = {}) {
const el = document.createElement("div");
const app = createApp({ render: () => h(component, props) });
app.config.globalProperties.$t = (key) => key;
app.mount(el);
return { el, app };
}
describe("share banner for public visitors", () => {
let mounted;
afterEach(() => mounted?.app.unmount());
it("sidebar share info shows the banner from bannerUrl", () => {
Object.assign(mockState, { shareInfo: { ...publicShareInfo }, req: {} });
mounted = render(FilesShareInfo, { hash: "abc", token: "" });
const img = mounted.el.querySelector(".banner img");
expect(img).not.toBeNull();
expect(img.getAttribute("src")).toBe(publicShareInfo.bannerUrl);
});
it("share info prompt shows the banner from bannerUrl", () => {
Object.assign(mockState, { shareInfo: { ...publicShareInfo }, req: {} });
mounted = render(PromptShareInfo);
const img = mounted.el.querySelector(".banner img");
expect(img).not.toBeNull();
expect(img.getAttribute("src")).toBe(publicShareInfo.bannerUrl);
});
it("no banner block when the share has no banner", () => {
Object.assign(mockState, { shareInfo: { ...publicShareInfo, bannerUrl: "" }, req: {} });
mounted = render(FilesShareInfo, { hash: "abc", token: "" });
expect(mounted.el.querySelector(".banner")).toBeNull();
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<div class="share-info">
<div class="share-info__box">
<div v-if="shareInfo.banner" class="banner">
<div v-if="shareInfo.bannerUrl" class="banner">
<img :src="shareInfo.bannerUrl" aria-hidden="true" />
</div>
<div v-if="shareInfo.title" class="share-info__box__element">
@@ -12,7 +12,7 @@
</div>
<div>
<hr v-if="shareInfo.banner || shareInfo.title || shareInfo.description" />
<hr v-if="shareInfo.bannerUrl || shareInfo.title || shareInfo.description" />
<div v-if="showShareInfo">
<div class="share-info__box__element">
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { store } = vi.hoisted(() => ({ store: { share: false, reqSource: "" } }));
vi.mock("@/store", () => ({
state: {
get req() {
return { source: store.reqSource };
},
},
mutations: {},
getters: { isShare: () => store.share },
}));
vi.mock("@/store/eventBus", () => ({ eventBus: { on: vi.fn(), off: vi.fn() } }));
vi.mock("@/api", () => ({ resourcesApi: {} }));
vi.mock("@/utils/buttons", () => ({ default: {} }));
vi.mock("@/utils/upload", () => ({ checkConflict: vi.fn() }));
vi.mock("@/utils", () => ({ url: {} }));
vi.mock("@/notify", () => ({ notify: {} }));
vi.mock("@/utils/appNotifications", () => ({
notifyMoveCopyComplete: vi.fn(),
notifyOperationError: vi.fn(),
notifyMoveCopyFailure: vi.fn(),
extractMoveCopyErrorMessage: vi.fn(),
}));
vi.mock("@/utils/notificationActions", () => ({ goToItemNotificationButton: vi.fn() }));
vi.mock("@/components/LoadingSpinner.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/files/PathPickerButton.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/files/ListingFilter.vue", () => ({ default: { render: () => null } }));
vi.mock("../files/FileList.vue", () => ({ default: { render: () => null } }));
import MoveCopy from "./MoveCopy.vue";
const needsDestination = (destSource) =>
MoveCopy.computed.needsDestination.call({
destSource,
isShareContext: MoveCopy.computed.isShareContext.call({}),
});
describe("MoveCopy needs a destination", () => {
beforeEach(() => {
store.share = false;
store.reqSource = "";
});
it("requires choosing a destination when the open request has no source (advanced search)", () => {
expect(needsDestination(null)).toBe(true);
});
it("is satisfied once a destination source is chosen", () => {
expect(needsDestination("data")).toBe(false);
});
it("does not require it in a normal listing, which has its own source", () => {
store.reqSource = "data";
expect(needsDestination(null)).toBe(false);
});
it("does not require it in a share", () => {
store.share = true;
expect(needsDestination(null)).toBe(false);
});
});
+11 -2
View File
@@ -58,11 +58,11 @@
<button
type="button"
v-else
:disabled="destContainsSrc || isLoading"
:disabled="destContainsSrc || needsDestination || isLoading"
class="button button--flat"
@click="performOperation"
:aria-label="operation === 'move' ? $t('general.move') : $t('general.copy')"
:title="operation === 'move' ? $t('general.move') : $t('general.copy')"
:title="needsDestination ? $t('sidebar.chooseSource') : (operation === 'move' ? $t('general.move') : $t('general.copy'))"
>
{{ operation === 'move' ? $t('general.move') : $t('general.copy') }}
</button>
@@ -170,6 +170,15 @@ export default {
isShareContext() {
return getters.isShare();
},
/**
* True when there is no source to fall back to and none has been chosen yet,
* e.g. in advanced search, where the open request is the list of results and
* has no source of its own. The user has to pick a destination first instead
* of the items silently going to the root of the first item's source.
*/
needsDestination() {
return !this.isShareContext && !this.destSource && !state.req?.source;
},
},
mounted() {
// If props.items is provided, use it. Otherwise use state
+2 -2
View File
@@ -187,7 +187,7 @@ import { notify } from "@/notify";
import { state, getters, mutations } from "@/store";
import { shareApi } from "@/api";
import { copyToClipboard } from "@/utils/clipboard";
import { fromNow } from "@/utils/moment";
import { fromNowSeconds } from "@/utils/moment";
import { buildItemUrl } from "@/utils/url";
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
import SettingsTable from "@/components/settings/Table.vue";
@@ -844,7 +844,7 @@ export default {
* @param {number} time
*/
humanTime(time) {
return fromNow(time, state.user?.locale)
return fromNowSeconds(time, state.user?.locale)
},
sort() {
this.links = this.links.sort((a, b) => {
+3 -10
View File
@@ -1,8 +1,8 @@
<template>
<div class="card-content">
<div class="share-info-content">
<div v-if="shareInfo.banner" class="banner">
<img :src="getShareBanner" aria-hidden="true" />
<div v-if="shareInfo.bannerUrl" class="banner">
<img :src="shareInfo.bannerUrl" aria-hidden="true" />
</div>
<div v-if="shareInfo.title" class="share-info-element">
<h3>{{ shareInfo.title }}</h3>
@@ -12,7 +12,7 @@
</div>
<div v-if="showShareInfo">
<hr v-if="shareInfo.banner || shareInfo.title || shareInfo.description" />
<hr v-if="shareInfo.bannerUrl || shareInfo.title || shareInfo.description" />
<div class="share-info-element">
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
</div>
@@ -34,7 +34,6 @@
</template>
<script>
import { resourcesApi } from "@/api";
import { state, getters } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import QrcodeVue from "qrcode.vue";
@@ -60,12 +59,6 @@ export default {
}
return state.shareInfo?.passwordValid
},
getShareBanner() {
if (state.shareInfo?.banner.startsWith("http")) {
return state.shareInfo?.banner;
}
return resourcesApi.getDownloadURLPublic(state.shareInfo, [state.shareInfo?.banner]);
},
shareInfo() {
return state.shareInfo;
},
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { store, mutations } = vi.hoisted(() => ({
store: { tool: null, share: false, user: {} },
mutations: { resetSelected: vi.fn(), showPrompt: vi.fn() },
}));
vi.mock("@/utils/auth", () => ({ logout: vi.fn() }));
vi.mock("@/utils/constants", () => ({ globalVars: {} }));
vi.mock("@/store", () => ({
state: {
get user() {
return store.user;
},
},
getters: {
currentTool: () => store.tool,
isShare: () => store.share,
},
mutations,
}));
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
vi.mock("./Links.vue", () => ({ default: { render: () => null } }));
vi.mock("@/utils", () => ({ url: {} }));
import General from "./General.vue";
const { computed, methods } = General;
describe("sidebar File actions in advanced search", () => {
beforeEach(() => {
vi.clearAllMocks();
store.tool = null;
store.share = false;
store.user = {};
});
it("detects the advanced search tool", () => {
expect(computed.isAdvancedSearch()).toBe(false);
store.tool = { component: "AdvancedSearch" };
expect(computed.isAdvancedSearch()).toBe(true);
});
it("is shown in advanced search even without create permissions", () => {
const ctx = { isAdvancedSearch: true, hideSidebarFileActions: true, isListingView: false };
expect(computed.showFileActions.call(ctx)).toBe(true);
});
it("respects the user's choice to hide the sidebar file actions", () => {
store.user = { hideSidebarFileActions: true };
expect(computed.showFileActions.call({ isAdvancedSearch: true })).toBe(false);
});
it("is not shown in a share's advanced search", () => {
store.share = true;
expect(computed.showFileActions.call({ isAdvancedSearch: true })).toBe(false);
});
it("keeps the old rule in the listing", () => {
expect(computed.showFileActions.call({ isAdvancedSearch: false, hideSidebarFileActions: false, isListingView: true })).toBe(true);
expect(computed.showFileActions.call({ isAdvancedSearch: false, hideSidebarFileActions: true, isListingView: true })).toBe(false);
expect(computed.showFileActions.call({ isAdvancedSearch: false, hideSidebarFileActions: false, isListingView: false })).toBe(false);
});
it("opens the menu on the current selection in advanced search, without the create-only mode", () => {
methods.openContextMenu.call({ isAdvancedSearch: true });
expect(mutations.resetSelected).not.toHaveBeenCalled();
expect(mutations.showPrompt).toHaveBeenCalledWith({ name: "ContextMenu", props: { showCentered: true } });
});
it("opens the create-only menu in the listing, as before", () => {
methods.openContextMenu.call({ isAdvancedSearch: false });
expect(mutations.resetSelected).toHaveBeenCalled();
expect(mutations.showPrompt).toHaveBeenCalledWith({
name: "ContextMenu",
props: { showCentered: true, createOnly: true },
});
});
});
+15 -2
View File
@@ -32,7 +32,7 @@
</div>
<div v-if="!disableQuickToggles" class="card-wrapper" @mouseleave="hideTooltip">
<div class="quick-toggles" :class="{ 'extra-padding': hasCreateOptions }">
<div class="quick-toggles" :class="{ 'extra-padding': hasCreateOptions || isAdvancedSearch }">
<div class="clickable" :class="{ active: user?.singleClick }" @click="toggleClick"
@mouseenter="showTooltip($event, $t('index.toggleClick'))" @mouseleave="hideTooltip" v-if="!isInvalidShare">
<i class="material-symbols">ads_click</i>
@@ -52,7 +52,7 @@
<!-- Sidebar file actions -->
<transition v-if="shareInfo.shareType !== 'upload'" name="expand" @before-enter="beforeEnter" @enter="enter"
@leave="leave">
<div v-if="!hideSidebarFileActions && isListingView" class="card-wrapper">
<div v-if="showFileActions" class="card-wrapper">
<button type="button" @click="openContextMenu" aria-label="File-Actions" data-testid="file-actions-button" class="action file-actions">
<i class="material-symbols">add</i>
{{ $t("sidebar.fileActions") }}
@@ -113,6 +113,14 @@ export default {
hideSidebarFileActions() {
return state.user?.hideSidebarFileActions || getters.isInvalidShare() || !this.hasCreateOptions;
},
isAdvancedSearch: () => getters.currentTool()?.component === "AdvancedSearch",
showFileActions() {
// Advanced search offers the actions for the selected results (not create), so it doesn't depend on create permissions.
if (this.isAdvancedSearch) {
return !state.user?.hideSidebarFileActions && !getters.isShare();
}
return !this.hideSidebarFileActions && this.isListingView;
},
settingsAllowed: () => !state.user?.disableSettings,
isSettings: () => getters.isSettings(),
isStickySidebar: () => getters.isStickySidebar(),
@@ -148,6 +156,11 @@ export default {
},
methods: {
openContextMenu() {
// In advanced search the menu acts on the current selection of results, so keep it.
if (this.isAdvancedSearch) {
mutations.showPrompt({ name: "ContextMenu", props: { showCentered: true } });
return;
}
mutations.resetSelected();
mutations.showPrompt({
name: "ContextMenu",
+1 -1
View File
@@ -17,7 +17,7 @@
>close</i>
</div>
<div v-if="showPwaInstall" class="button release-banner">
<button type="button" @click="installPwa">{{ $t("pwa.install") }}</button>
<a type="button" @click="installPwa">{{ $t("pwa.install") }}</a>
<i @click="dismissPwaInstall" role="button" tabindex="0" aria-label="close-banner" class="material-symbols">close</i>
</div>
<SidebarSettings v-if="isSettings"></SidebarSettings>
-5
View File
@@ -68,10 +68,6 @@ export function enablePlyrVideoLoadingIndicator(player, onLoadingChange) {
seeking = false;
sync();
};
const onStalled = () => {
waiting = true;
sync();
};
const onError = () => {
waiting = false;
seeking = false;
@@ -96,7 +92,6 @@ export function enablePlyrVideoLoadingIndicator(player, onLoadingChange) {
canplay: onCanPlay,
seeking: onSeeking,
seeked: onSeeked,
stalled: onStalled,
error: onError,
emptied: onEmptied,
};
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { enablePlyrVideoLoadingIndicator } from '@/plyr/plyrVideoLoading.js';
function fakePlayer() {
const handlers = {};
const media = {
currentSrc: 'video.mp4',
readyState: HTMLMediaElement.HAVE_NOTHING,
networkState: HTMLMediaElement.NETWORK_IDLE,
};
return {
media,
on: (evt, fn) => { handlers[evt] = fn; },
off: (evt) => { delete handlers[evt]; },
emit: (evt) => handlers[evt]?.(),
};
}
function startPlayback() {
const player = fakePlayer();
const states = [];
enablePlyrVideoLoadingIndicator(player, (show) => states.push(show));
player.emit('play');
player.media.readyState = HTMLMediaElement.HAVE_ENOUGH_DATA;
player.media.networkState = HTMLMediaElement.NETWORK_LOADING;
player.emit('playing');
return { player, states };
}
describe('video loading indicator', () => {
it('stays hidden when the fetch stalls but playback continues from the buffer', () => {
const { player, states } = startPlayback();
player.emit('stalled');
expect(states.at(-1)).toBe(false);
});
it('shows while playback waits for data', () => {
const { player, states } = startPlayback();
player.media.readyState = HTMLMediaElement.HAVE_CURRENT_DATA;
player.emit('waiting');
expect(states.at(-1)).toBe(true);
});
});
+85 -3
View File
@@ -48,14 +48,41 @@ vi.mock("@/api/resources", () => ({
vi.mock("@/api/media", () => ({}));
import {
HTML_PREVIEW_CSP,
HTML_PREVIEW_SANDBOX,
buildHtmlPreview,
buildPreviewResourceUrl,
isAllowedTopNavigationUrl,
isLocalResourceReference,
rewriteCssContent,
rewriteSrcset,
} from "./htmlPreview.ts";
import { state } from "@/store/index.ts";
describe("HTML_PREVIEW_SANDBOX", () => {
it("uses same-origin without scripts so the session cookie reaches subresources", () => {
expect(HTML_PREVIEW_SANDBOX).toContain("allow-same-origin");
expect(HTML_PREVIEW_SANDBOX).not.toContain("allow-scripts");
});
it("lets external popups escape the sandbox so they render normally", () => {
expect(HTML_PREVIEW_SANDBOX.split(/\s+/)).toContain("allow-popups-to-escape-sandbox");
});
it("never combines allow-scripts with allow-same-origin (XSS breakout risk)", () => {
const tokens = HTML_PREVIEW_SANDBOX.split(/\s+/);
expect(tokens.includes("allow-scripts") && tokens.includes("allow-same-origin")).toBe(false);
});
it("allows https passive resources but never scripts", () => {
expect(HTML_PREVIEW_CSP).toContain("img-src");
expect(HTML_PREVIEW_CSP).toContain("https:");
expect(HTML_PREVIEW_CSP).toContain("script-src 'none'");
expect(HTML_PREVIEW_CSP).not.toContain("script-src https:");
expect(HTML_PREVIEW_CSP).toContain("connect-src 'none'");
});
});
describe("isLocalResourceReference", () => {
it("treats relative paths as local", () => {
expect(isLocalResourceReference("./style.css")).toBe(true);
@@ -140,8 +167,44 @@ describe("rewriteCssContent", () => {
});
});
describe("isAllowedTopNavigationUrl", () => {
it("accepts in-app HTML viewer routes for the active source", () => {
const href = `${window.location.origin}/files/src/pages/other.html`;
expect(isAllowedTopNavigationUrl(href, "src")).toBe(true);
});
it("rejects external and non-html viewer paths", () => {
expect(isAllowedTopNavigationUrl("https://evil.test/x.html", "src")).toBe(false);
expect(isAllowedTopNavigationUrl(`${window.location.origin}/files/src/pages/other.txt`, "src")).toBe(false);
});
});
describe("buildHtmlPreview", () => {
it("rewrites linked assets and strips user scripts from sandboxed srcdoc", () => {
it("keeps the fragment on cross-page viewer links", () => {
const { srcdoc } = buildHtmlPreview('<a href="other.html#section-2">Next</a>', "/pages/index.html", "src");
const anchor = new DOMParser().parseFromString(srcdoc, "text/html").querySelector("a");
expect(anchor.getAttribute("target")).toBe("_top");
expect(anchor.getAttribute("href")).toBe(`${window.location.origin}/files/src/pages/other.html#section-2`);
});
it("routes same-source HTML links to the viewer with target=_top", () => {
const { srcdoc } = buildHtmlPreview('<a href="other.html">Next</a>', "/pages/index.html", "src");
const doc = new DOMParser().parseFromString(srcdoc, "text/html");
const anchor = doc.querySelector("a");
expect(anchor.getAttribute("target")).toBe("_top");
expect(anchor.getAttribute("href")).toBe(`${window.location.origin}/files/src/pages/other.html`);
expect(anchor.getAttribute("href")).not.toContain("viewToken");
expect(doc.querySelector("script")).toBeNull();
});
it("opens external http(s) links in a new tab", () => {
const { srcdoc } = buildHtmlPreview('<a href="https://example.com/page">External</a>', "/index.html", "src");
const anchor = new DOMParser().parseFromString(srcdoc, "text/html").querySelector("a");
expect(anchor.getAttribute("target")).toBe("_blank");
expect(anchor.getAttribute("rel")).toBe("noopener noreferrer");
});
it("rewrites linked assets and strips user scripts from the preview document", () => {
const html = `<!DOCTYPE html>
<html>
<head>
@@ -157,10 +220,12 @@ describe("buildHtmlPreview", () => {
const { srcdoc } = buildHtmlPreview(html, "/pages/index.html", "src");
expect(srcdoc).toContain(encodeURIComponent("/pages/theme.css"));
expect(srcdoc).toContain(encodeURIComponent("/pages/photo.jpg"));
expect(srcdoc).toContain(encodeURIComponent("/pages/other.html"));
const rendered = new DOMParser().parseFromString(srcdoc, "text/html");
expect(rendered.querySelector("a").getAttribute("href")).toBe(
`${window.location.origin}/files/src/pages/other.html`,
);
expect(srcdoc).not.toContain("app.js");
expect(srcdoc).not.toContain("<script src=");
expect(srcdoc).toContain("scrollIntoView");
expect(srcdoc).not.toContain("javascript:");
});
@@ -170,6 +235,23 @@ describe("buildHtmlPreview", () => {
expect(srcdoc).not.toContain("javascript:");
});
it("declares UTF-8, CSP, and preserves multilingual text", () => {
const text = "中文 — café € 🎥";
const { srcdoc } = buildHtmlPreview(`<p>${text}</p><meta http-equiv="refresh" content="0;url=https://evil.test">`, "/index.html", "src");
const doc = new DOMParser().parseFromString(srcdoc, "text/html");
expect(doc.head.firstElementChild?.getAttribute("charset")).toBe("utf-8");
expect(doc.querySelector('meta[http-equiv="Content-Security-Policy"]')?.getAttribute("content")).toBe(HTML_PREVIEW_CSP);
expect(doc.querySelector("p").textContent).toBe(text);
expect(doc.querySelector('meta[http-equiv="refresh"]')).toBeNull();
});
it("does not honor forged navigation markers from uploaded HTML", () => {
const { srcdoc } = buildHtmlPreview('<a href="https://evil.test/page.html" data-filebrowser-html-link="true">External</a>', "/index.html", "src");
const doc = new DOMParser().parseFromString(srcdoc, "text/html");
expect(doc.querySelector("a").hasAttribute("data-filebrowser-html-link")).toBe(false);
expect(doc.querySelector("a").getAttribute("target")).toBe("_blank");
});
it("preserves inline SVG", () => {
const html = `<html><body><svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="4"/></svg></body></html>`;
const { srcdoc } = buildHtmlPreview(html, "/index.html", "src");
+113 -34
View File
@@ -2,8 +2,7 @@ import DOMPurify from "dompurify";
import { getters, state } from "@/store/index.ts";
import { getCachedViewToken } from "@/api/viewToken.js";
import { getViewURL } from "@/api/resources.js";
import { getParentDir, resolveRelativePath } from "@/utils/url.js";
import { globalVars } from "./constants.js";
import { buildItemUrl, getParentDir, resolveRelativePath } from "@/utils/url.js";
export const HTML_SANITIZE_CONFIG = {
USE_PROFILES: { html: true, svg: true, svgFilters: true },
@@ -13,6 +12,28 @@ export const HTML_SANITIZE_CONFIG = {
FORBID_ATTR: ["target", "formaction"],
};
/**
* Sandboxed HTML preview iframe flags (no allow-scripts — scripts must never share an origin with the parent).
* allow-popups-to-escape-sandbox lets external links (target=_blank, rel=noopener) open as normal
* pages instead of inheriting the script-less sandbox and breaking.
*/
export const HTML_PREVIEW_SANDBOX =
"allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation";
// Firefox does not match CSP 'self' against the parent origin inside synthetic
// documents (blob:/about:srcdoc), so the concrete origin is listed as well.
// Chromium and WebKit accept 'self'; keeping both is harmless and portable.
const PREVIEW_ORIGIN =
typeof window !== "undefined" ? window.location.origin : "";
const SAME_ORIGIN = PREVIEW_ORIGIN ? `'self' ${PREVIEW_ORIGIN}` : "'self'";
// Passive resources may load from any HTTPS origin, matching normal HTML.
// Scripts stay disabled (the iframe sandbox has no allow-scripts, and a script
// in a same-origin frame could reach the parent app), and connect-src/frame-src
// stay locked down.
export const HTML_PREVIEW_CSP =
`default-src 'none'; script-src 'none'; img-src ${SAME_ORIGIN} https: data: blob:; media-src ${SAME_ORIGIN} https: data: blob:; style-src ${SAME_ORIGIN} https: 'unsafe-inline' data:; font-src ${SAME_ORIGIN} https: data: blob:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'`;
const CSS_URL_PATTERN = /url\(\s*(['"]?)([^'")]+?)\1\s*\)/gi;
const CSS_IMPORT_URL_PATTERN = /@import\s+url\(\s*['"]?([^'")\s]+)['"]?\s*\)/gi;
const CSS_IMPORT_STRING_PATTERN = /@import\s+['"]([^'"]+)['"]/gi;
@@ -47,26 +68,6 @@ const RESOURCE_ATTRIBUTES: Array<[string, string]> = [
const ALLOWED_ABSOLUTE_URI_PATTERN = /^(https?:|data:|mailto:|tel:|#)/i;
// to stop relative hrefs from navigating the srcdoc iframe against the parent, and
// to keep anchor links working inside the iframe itself instead (was opening the app inside the iframe)
const NAV_GUARD_SCRIPT = `document.addEventListener("click", function (e) {
var a = e.target && e.target.closest ? e.target.closest("a") : null;
if (!a) return;
var href = a.getAttribute("href") || "";
if (href.charAt(0) !== "#") {
// Absolute links are left alone -- relative links would resolve to about:blank.
if (!/^[a-z][a-z0-9+.-]*:/i.test(href) && href.slice(0, 2) !== "//") {
e.preventDefault();
}
return;
}
var id = href.slice(1);
var el = id ? document.getElementById(id) : document.documentElement;
if (!el) return;
e.preventDefault();
el.scrollIntoView();
}, true);`;
function viewTokenForSibling(
resolvedPath: string,
baseFilePath: string,
@@ -277,20 +278,66 @@ export function rewriteDocumentStyles(
}
}
function navigationGuard(htmlEl: HTMLElement): string {
function buildHtmlViewerUrl(source: string, filePath: string): string {
const pathOnly = filePath.split(/[?#]/)[0];
const hashIndex = filePath.indexOf("#");
const url = new URL(buildItemUrl(source, pathOnly, true), window.location.origin);
// Keep the in-page anchor (page.html#section) so cross-page links land on the target section.
if (hashIndex >= 0) url.hash = filePath.slice(hashIndex + 1);
return url.href;
}
/** Same-origin FileBrowser viewer routes only (used to gate target="_top"). */
export function isAllowedTopNavigationUrl(href: string, source: string): boolean {
try {
const url = new URL(href, window.location.origin);
if (url.origin !== window.location.origin || url.username || url.password) {
return false;
}
const prefix = new URL(buildItemUrl(source, "/", true), window.location.origin);
const prefixPath = prefix.pathname.endsWith("/")
? prefix.pathname
: `${prefix.pathname}/`;
if (!url.pathname.startsWith(prefixPath)) {
return false;
}
const relative = decodeURIComponent(url.pathname.slice(prefixPath.length));
// biome-ignore lint/suspicious/noControlCharactersInRegex: Reject control characters in navigation paths.
if (!relative || !/\.html?$/i.test(relative) || /[\x00-\x1f\\]/.test(relative)) {
return false;
}
return !relative.split("/").some((part) => part === "." || part === "..");
} catch {
return false;
}
}
function applyPreviewLinkTargets(doc: ParentNode, source: string, pageViewerUrls: Set<string>): void {
for (const anchor of doc.querySelectorAll("a[href]")) {
const href = anchor.getAttribute("href") ?? "";
if (pageViewerUrls.has(href) && isAllowedTopNavigationUrl(href, source)) {
anchor.setAttribute("target", "_top");
continue;
}
if (/^https?:/i.test(href)) {
anchor.setAttribute("target", "_blank");
anchor.setAttribute("rel", "noopener noreferrer");
}
}
}
function wrapPreviewDocument(htmlEl: HTMLElement): string {
const doc = htmlEl.ownerDocument;
const head = doc.head ?? htmlEl.querySelector("head");
if (head) {
const base = doc.createElement("base");
base.setAttribute("href", "about:blank");
head.insertBefore(base, head.firstChild);
const charset = doc.createElement("meta");
charset.setAttribute("charset", "utf-8");
head.insertBefore(charset, head.firstChild);
const guard = doc.createElement("script");
if (typeof globalVars.cspNonce === "string" && globalVars.cspNonce !== "") {
guard.setAttribute("nonce", globalVars.cspNonce);
}
guard.textContent = NAV_GUARD_SCRIPT;
head.appendChild(guard);
const csp = doc.createElement("meta");
csp.setAttribute("http-equiv", "Content-Security-Policy");
csp.setAttribute("content", HTML_PREVIEW_CSP);
head.insertBefore(csp, charset.nextSibling);
}
return `<!DOCTYPE html>\n${htmlEl.outerHTML}`;
}
@@ -305,6 +352,18 @@ export function buildHtmlPreview(
source: string,
): HtmlPreview {
const doc = new DOMParser().parseFromString(content, "text/html");
const pageLinks = new Map<string, string>();
for (const anchor of doc.querySelectorAll("a[href]")) {
const href = anchor.getAttribute("href") ?? "";
if (!isLocalResourceReference(href)) continue;
const resolved = resolveRelativePath(filePath, href);
const pathOnly = resolved.split(/[?#]/)[0];
if (!pathOnly || !/\.html?$/i.test(pathOnly)) continue;
pageLinks.set(
buildPreviewResourceUrl(href, filePath, source),
buildHtmlViewerUrl(source, resolved),
);
}
rewriteHtmlResources(doc, filePath, source);
rewriteDocumentStyles(doc, filePath, source);
@@ -312,8 +371,28 @@ export function buildHtmlPreview(
DOMPurify.addHook("uponSanitizeAttribute", blockUnsafeUriAttributes);
try {
const sanitized = DOMPurify.sanitize(serialized, { ...HTML_SANITIZE_CONFIG, RETURN_DOM: true, }) as unknown as HTMLElement;
return { srcdoc: navigationGuard(sanitized) };
const sanitized = DOMPurify.sanitize(serialized, {
...HTML_SANITIZE_CONFIG,
RETURN_DOM: true,
}) as unknown as HTMLElement;
for (const anchor of sanitized.querySelectorAll("a")) {
anchor.removeAttribute("data-filebrowser-html-link");
}
const pageViewerUrls = new Set<string>();
for (const anchor of sanitized.querySelectorAll("a[href]")) {
const viewerURL = pageLinks.get(anchor.getAttribute("href") ?? "");
if (!viewerURL || !isAllowedTopNavigationUrl(viewerURL, source)) {
continue;
}
anchor.setAttribute("href", viewerURL);
pageViewerUrls.add(viewerURL);
}
applyPreviewLinkTargets(sanitized, source, pageViewerUrls);
return { srcdoc: wrapPreviewDocument(sanitized) };
} finally {
DOMPurify.removeHook("uponSanitizeAttribute", blockUnsafeUriAttributes);
}
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { createHtmlPreviewLoader } from "./htmlPreviewLoader.ts";
function setup() {
let serial = 0;
const createURL = vi.fn(() => `blob:preview-${++serial}`);
const revokeURL = vi.fn();
const frame = { location: { replace: vi.fn() } };
return { frame, createURL, revokeURL, loader: createHtmlPreviewLoader(createURL, revokeURL) };
}
describe("HTML preview history", () => {
it("marks the default blob as UTF-8", () => {
let blob;
const createObjectURL = vi.fn((value) => { blob = value; return "blob:utf8"; });
vi.stubGlobal("URL", { createObjectURL, revokeObjectURL: vi.fn() });
try {
const loader = createHtmlPreviewLoader();
loader.load({ location: { replace: vi.fn() } }, "中文 — café € 🎥");
expect(blob.type).toBe("text/html;charset=utf-8");
loader.dispose();
} finally {
vi.unstubAllGlobals();
}
});
it("replaces the frame document for each page and avoids duplicate renders", () => {
const { frame, createURL, revokeURL, loader } = setup();
loader.load(frame, "index");
loader.load(frame, "post");
loader.load(frame, "post");
expect(frame.location.replace.mock.calls).toEqual([["blob:preview-1"], ["blob:preview-2"]]);
expect(createURL).toHaveBeenCalledTimes(2);
expect(revokeURL).toHaveBeenCalledWith("blob:preview-1");
});
it("reloads the document when browser Back selects the earlier app route", () => {
const { frame, loader } = setup();
loader.load(frame, "index");
loader.load(frame, "post");
loader.load(frame, "index");
expect(frame.location.replace).toHaveBeenCalledTimes(3);
});
it("renders into a recreated iframe and releases URLs on unmount", () => {
const { frame, loader, revokeURL } = setup();
loader.load(frame, "index");
const replacement = { location: { replace: vi.fn() } };
loader.load(replacement, "index");
expect(replacement.location.replace).toHaveBeenCalledWith("blob:preview-2");
loader.dispose();
loader.dispose();
expect(revokeURL.mock.calls).toEqual([["blob:preview-1"], ["blob:preview-2"]]);
});
it("releases a failed navigation URL without discarding the current document", () => {
const { frame, loader, revokeURL } = setup();
loader.load(frame, "index");
frame.location.replace.mockImplementationOnce(() => { throw new Error("blocked"); });
expect(() => loader.load(frame, "post")).toThrow("blocked");
expect(revokeURL).toHaveBeenCalledWith("blob:preview-2");
expect(revokeURL).not.toHaveBeenCalledWith("blob:preview-1");
loader.dispose();
expect(revokeURL).toHaveBeenCalledWith("blob:preview-1");
});
});
+38
View File
@@ -0,0 +1,38 @@
// Keep iframe document updates out of the browser's joint session history.
// With allow-same-origin (and no allow-scripts), blob documents are same-origin
// so subresources use the session cookie; only sanitized HTML is loaded here.
export function createHtmlPreviewLoader(
createURL: (html: string) => string = (html) =>
URL.createObjectURL(new Blob([html], { type: "text/html;charset=utf-8" })),
revokeURL: (url: string) => void = (url) => URL.revokeObjectURL(url),
) {
let currentURL: string | null = null;
let currentFrame: Window | null = null;
let currentHTML: string | null = null;
return {
load(frame: Window, html: string) {
if (frame === currentFrame && html === currentHTML) return;
const nextURL = createURL(html);
try {
// replace(), rather than assigning src/srcdoc, avoids creating an
// iframe history entry alongside each app-router navigation.
frame.location.replace(nextURL);
} catch (error) {
revokeURL(nextURL);
throw error;
}
const previousURL = currentURL;
currentURL = nextURL;
currentFrame = frame;
currentHTML = html;
if (previousURL !== null) revokeURL(previousURL);
},
dispose() {
if (currentURL !== null) revokeURL(currentURL);
currentURL = null;
currentFrame = null;
currentHTML = null;
},
};
}
+26
View File
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fromNow, fromNowSeconds } from "./moment.ts";
describe("relative timestamps", () => {
afterEach(() => {
vi.useRealTimers();
});
it("formats a share expiration in Unix seconds as a future date", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-21T12:00:00Z"));
// A share with a duration of 240 hours (10 days).
const expires = Math.floor(Date.parse("2026-10-31T12:00:00Z") / 1000);
expect(fromNowSeconds(expires, "en-us")).toBe("next week");
});
it("preserves the existing millisecond default for other callers", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-21T12:00:00Z"));
const threeDaysLaterMs = Date.parse("2026-10-24T12:00:00Z");
expect(fromNow(threeDaysLaterMs, "en-us")).toBe("in 3 days");
expect(fromNowSeconds(threeDaysLaterMs / 1000, "en-us")).toBe("in 3 days");
});
});
+5
View File
@@ -52,6 +52,11 @@ export function fromNow(date: DateInput, locale: string = 'en-us', { unit }: { u
return 'just now';
}
/** Format Unix timestamps in seconds, such as share expiration values. */
export function fromNowSeconds(timestamp: number, locale: string = 'en-us'): string {
return fromNow(timestamp, locale, { unit: "seconds" });
}
export function formatTimestamp(
date: DateInput,
locale: string = 'en-us',
+1 -1
View File
@@ -441,7 +441,7 @@ export default {
if (!link.category.startsWith('source')) {
continue;
}
targetPath = `/${link.sourceName}${link.target}`;
targetPath = `/files/${link.sourceName}${link.target}`;
break;
}
}
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mutations } = vi.hoisted(() => ({
mutations: { resetSelected: vi.fn() },
}));
vi.mock("@/store", () => ({ getters: {}, mutations }));
vi.mock("@/utils/constants", () => ({ tools: [] }));
vi.mock("@/utils/asyncComponent.js", () => ({ createAsyncComponent: () => ({}) }));
vi.mock("@/views/Errors.vue", () => ({ default: { render: () => null } }));
import Tools from "./Tools.vue";
const { methods } = Tools;
describe("Tools advanced search close button", () => {
let $router;
beforeEach(() => {
vi.clearAllMocks();
window.history.replaceState({}, "");
$router = { go: vi.fn(), push: vi.fn(() => Promise.resolve()) };
});
it("goes back to the page the search was opened from", () => {
window.history.replaceState({ back: "/files/data/photos/" }, "");
methods.closeAdvancedSearch.call({ $router });
expect($router.go).toHaveBeenCalledWith(-1);
expect($router.push).not.toHaveBeenCalled();
});
it("opens the files when there is no previous page, e.g. the search URL was opened directly", () => {
methods.closeAdvancedSearch.call({ $router });
expect($router.go).not.toHaveBeenCalled();
expect($router.push).toHaveBeenCalledWith({ path: "/files" });
});
it("drops the selection so it doesn't leak into the page it returns to", () => {
methods.closeAdvancedSearch.call({ $router });
expect(mutations.resetSelected).toHaveBeenCalled();
});
});
+44 -1
View File
@@ -1,5 +1,15 @@
<template>
<div class="tools-wrapper" :class="{ 'tools-wrapper--advanced-search': isAdvancedSearchTool }">
<button
v-if="isAdvancedSearchTool"
type="button"
class="tools-close-button"
:aria-label="$t('general.close')"
:title="$t('general.close')"
@click="closeAdvancedSearch"
>
<i class="material-symbols">close</i>
</button>
<!-- Show tools list when no tool is selected -->
<div v-if="showToolsList" class="tools-list-container">
<div class="card-title">
@@ -47,7 +57,7 @@
<script>
import { createAsyncComponent } from "@/utils/asyncComponent.js";
import { tools as allTools } from "@/utils/constants";
import { getters } from "@/store";
import { getters, mutations } from "@/store";
import { availableTools, hasToolAccess, toolIdFromPath } from "@/utils/toolAccess";
import Errors from "@/views/Errors.vue";
@@ -90,6 +100,17 @@ export default {
return this.$route.path === "/tools/advancedSearch";
},
},
methods: {
/** Leave the advanced search and go back to where it was opened from (or to the files if there is no previous page). */
closeAdvancedSearch() {
mutations.resetSelected();
if (window.history.state?.back) {
this.$router.go(-1);
return;
}
void this.$router.push({ path: "/files" }).catch(() => {});
},
},
};
</script>
@@ -149,6 +170,28 @@ export default {
margin: 0.5em 0 1em;
}
.tools-close-button {
align-self: flex-start;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25em;
height: 2.25em;
margin: 0.5em 0 0 0.75em;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: #546e7a;
cursor: pointer;
}
.tools-close-button:hover,
.tools-close-button:focus-visible {
color: var(--textPrimary);
background: color-mix(in srgb, var(--surfaceSecondary) 75%, transparent);
}
.tools-wrapper--advanced-search {
max-width: none;
width: 100%;
+40 -5
View File
@@ -5,13 +5,13 @@
:class="{ 'html-viewer-mode': isHtml }"
:style="htmlViewerStyle"
>
<!-- Never add allow-scripts together with allow-same-origin (see HTML_PREVIEW_SANDBOX). -->
<iframe
v-if="isHtml"
ref="viewerEl"
:key="req.path"
class="html-content"
:srcdoc="htmlPreview.srcdoc"
sandbox="allow-scripts allow-popups"
src="about:blank"
:sandbox="HTML_PREVIEW_SANDBOX"
referrerpolicy="no-referrer"
title="HTML preview"
@load="applyHtmlPreviewHeight"
@@ -50,8 +50,10 @@ import { createScrollSyncGuard } from "@/utils/markdownScrollSync";
import { copyToClipboard } from "@/utils/clipboard";
import { globalVars } from "@/utils/constants";
import { isHtmlMimeType } from "@/utils/mimetype";
import { createHtmlPreviewLoader } from "@/utils/htmlPreviewLoader";
import FloatingActionButton from "@/components/settings/FloatingActionButton.vue";
import {
HTML_PREVIEW_SANDBOX,
buildHtmlPreview,
buildPreviewResourceUrl,
rewriteHtmlResources,
@@ -272,6 +274,24 @@ const htmlPreview = computed(() => {
return buildHtmlPreview(content.value, state.req.path ?? "", state.req.source ?? "");
});
const htmlPreviewLoader = createHtmlPreviewLoader();
function renderHtmlPreview() {
if (!isHtml.value) {
htmlPreviewLoader.dispose();
return;
}
const iframe = viewerEl.value as HTMLIFrameElement | null;
if (iframe?.contentWindow) {
htmlPreviewLoader.load(iframe.contentWindow, htmlPreview.value.srcdoc);
}
}
watch(htmlPreview, async () => {
await nextTick();
renderHtmlPreview();
}, { flush: "post" });
const renderedContent = computed(() => {
void mathReady.value;
return parseMarkdown(content.value, state.req.path ?? "", state.req.source ?? "");
@@ -348,6 +368,7 @@ onMounted(async () => {
reinit();
observeResize();
await nextTick();
renderHtmlPreview();
attachScrollListener(getScrollContainer());
});
@@ -358,6 +379,7 @@ onBeforeUnmount(() => {
});
onUnmounted(() => {
htmlPreviewLoader.dispose();
attachScrollListener(null);
unobserveResize();
@@ -373,8 +395,21 @@ function toggleSplitView() {
function applyHtmlPreviewHeight() {
const iframe = viewerEl.value as HTMLIFrameElement | null;
if (!iframe || !isHtml.value) return;
const available = window.innerHeight - iframe.getBoundingClientRect().top;
iframe.style.height = `${available}px`;
const minAvailable = window.innerHeight - iframe.getBoundingClientRect().top;
try {
const doc = iframe.contentDocument;
if (doc?.documentElement) {
const contentHeight = Math.max(
doc.documentElement.scrollHeight,
doc.body?.scrollHeight ?? 0,
);
iframe.style.height = `${Math.max(contentHeight, minAvailable)}px`;
return;
}
} catch {
// Fall back to viewport height if the frame is not yet same-origin readable.
}
iframe.style.height = `${minAvailable}px`;
}
function observeResize() {
+2
View File
@@ -828,6 +828,8 @@ export default {
fullscreen: {
enabled: true,
fallback: true,
// iPhone Safari has no element Fullscreen API; only video.webkitEnterFullscreen() hides the browser UI.
iosNative: true,
container: '.plyr-viewer',
},
iconUrl: `${globalVars.baseURL}public/static/img/plyr.svg`,
+2 -2
View File
@@ -105,7 +105,7 @@ import SettingsTable from "@/components/settings/Table.vue";
import SettingsButton from "@/components/settings/SettingsButton.vue";
import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import { fromNow } from '@/utils/moment';
import { fromNowSeconds } from '@/utils/moment';
import { eventBus } from "@/store/eventBus";
import { copyToClipboard } from "@/utils/clipboard";
@@ -277,7 +277,7 @@ export default {
* @param {any} time
*/
humanTime(time) {
return fromNow(time);
return fromNowSeconds(time);
},
/**
* @param {any} share
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more