mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 13:12:00 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
116a7c3677 | ||
|
|
10a26832dd | ||
|
|
0dfab77d6d | ||
|
|
7bd9c67eb7 | ||
|
|
009be1a06d | ||
|
|
272a0fab9d | ||
|
|
63a517de1f | ||
|
|
e39b534531 |
No files matched your search
+6
-1
@@ -9,12 +9,17 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- Square image previews: avoid the full-resolution crop copy in imaging.Fill (#3340)
|
||||
|
||||
**Bug Fixes**:
|
||||
- fix(http): Unix socket hardening — default `http.socketFileMode` is `0660`; the socket is created under a restrictive umask and then `chmod`/`chown` apply your configured mode; on filesystem sockets, forwarded headers require `http.unixSocketTrustedUIDs` and/or `http.unixSocketTrustedGIDs`, with `http.unixSocketTrustAllLocalPeers: true` as an explicit opt-in for pre-#3309 trust-all behavior (startup warning) (#3309)
|
||||
- fix(users): save the prompt close button and new file templates when editing a user (#3342) (#3323)
|
||||
- fix(upload): serialize directory creation before uploading files (#3321)
|
||||
- fix(preview): open RAW, HEIC and rendered documents in the viewer when previews are disabled (#3313)
|
||||
- fix(users): keep requested profile values when creating a user
|
||||
- fix advanced user toggle duplicate (#3353) (#3324)
|
||||
|
||||
**Upgrade notes** (Unix socket listeners):
|
||||
- If you listen on a filesystem `http.socket` with `http.trustProxyHeaders: true`, set `http.unixSocketTrustedUIDs` and/or `http.unixSocketTrustedGIDs` to the UID/GID(s) from Linux peer credentials (`SO_PEERCRED`) that may set `X-Forwarded-*`. A GID allowlist trusts every peer with that GID (for example when the proxy and FileBrowser share a group), not only one process. If you relied on empty allowlists before, set `http.unixSocketTrustAllLocalPeers: true` (a warning is logged).
|
||||
- Default `http.socketFileMode` is `0660`; use optional `http.socketGroup` when the proxy runs as another user in the same group.
|
||||
|
||||
## v2.1.4
|
||||
|
||||
**Security**:
|
||||
@@ -59,7 +64,7 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- [Moderate] Authenticated `GET /api/resources/view` no longer accepts view tokens minted for a public share when the caller lacks view permission on the requested source (GHSA-wxj5-g3qp-g732) reporter @BlueN0r
|
||||
- [Low] SSE source-update and user-targeted delivery hold the client registry lock through nonblocking sends, preventing concurrent map mutation and send-on-closed-channel panics when Realtime clients connect, disconnect, or shut down (GHSA-p9wm-f5jj-75pj) reporter @tao0845
|
||||
- [High] Incomplete symlink-containment remediation: an authenticated non-admin user could create a relative symlink via archive extraction, relocate it with WebDAV MOVE, and write through it to arbitrary host paths with the service account's privileges (GHSA-3j46-5w28-f8p2) reporter @Saku0512
|
||||
- [Moderate] Source-root containment bypass: create, write, delete, and move operations now resolve symlinks uncached and enforce the caller's scope, closing escapes through pre-existing out-of-source symlinks on upload, PUT, PATCH, delete, and WebDAV endpoints (GHSA-j764-qg3m-rhv7) reporter @chaoming0625
|
||||
- [Moderate] Source-root containment bypass: create, write, delete, and move operations now resolve symlinks uncached and enforce the caller's scope, closing API escapes through pre-existing out-of-source symlinks on upload, PUT, PATCH, delete, and WebDAV endpoints; RealPathCache entries for affected index paths are invalidated after mutations (GHSA-j764-qg3m-rhv7) reporter @chaoming0625
|
||||
- [Moderate] Preview reads resolve symlinks within the caller's scope so authenticated preview cannot follow an out-of-scope symlink target (GHSA-g2q5-r6ch-v25f) reporter @tao0845
|
||||
- [Moderate] Archive downloads and extraction no longer read or write through symlinks that resolve outside the caller's share or user scope; escaping archive-walk entries are skipped (GHSA-qqf3-2wvq-q4w9) reporter @SmokinCaterpillar
|
||||
- [Moderate] Share directory-archive downloads now apply the share owner's access rules to every entry, closing a bypass where anonymous share visitors could download admin-denied content (GHSA-9fcr-gm58-p839) reporter @Tan-JunWei
|
||||
|
||||
@@ -655,6 +655,7 @@ func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
|
||||
if utils.CleanIndexPathSegment(indexPath) == "" {
|
||||
return fmt.Errorf("refusing to delete source root directory: %s", indexPath)
|
||||
}
|
||||
deleteIndexPath := indexPath
|
||||
realPath, err := index.GetRealPathForRemoval(boundIndexPath, indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -673,9 +674,10 @@ func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear cache entries
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.IsDirCache.Delete(realPath + ":isdir")
|
||||
index.InvalidateRealPathCache(deleteIndexPath, boundIndexPath)
|
||||
if parent := utils.GetParentDirectoryPath(deleteIndexPath); parent != "" && parent != "/" {
|
||||
index.InvalidateRealPathCache(parent, boundIndexPath)
|
||||
}
|
||||
|
||||
// Remove metadata from index
|
||||
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
|
||||
@@ -727,8 +729,7 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
if !Exists(realPath) {
|
||||
// Directory no longer exists, remove it from the index
|
||||
// This clears both Directories and DirectoriesLedger maps
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.IsDirCache.Delete(realPath + ":isdir")
|
||||
idx.InvalidateRealPathCache(path)
|
||||
idx.DeleteMetadata(path, true, false)
|
||||
return nil
|
||||
}
|
||||
@@ -966,6 +967,11 @@ func WriteDirectory(boundIndexPath string, opts utils.FileOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
idx.InvalidateRealPathCache(opts.Path, boundIndexPath)
|
||||
if parent := utils.GetParentDirectoryPath(opts.Path); parent != "" && parent != "/" {
|
||||
idx.InvalidateRealPathCache(parent, boundIndexPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1041,6 +1047,11 @@ func writeFile(source, boundIndexPath, path string, in io.Reader) error {
|
||||
}
|
||||
}
|
||||
|
||||
idx.InvalidateRealPathCache(path, boundIndexPath)
|
||||
if parent := utils.GetParentDirectoryPath(path); parent != "" && parent != "/" {
|
||||
idx.InvalidateRealPathCache(parent, boundIndexPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -446,8 +446,29 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
|
||||
return nil, wrappedReader, exif.ErrNoThumbnail
|
||||
}
|
||||
|
||||
// Read IFD1's Orientation first; fall back to IFD0's Orientation if not specified (per MWG guidelines)
|
||||
orientStr := ""
|
||||
tags1, err1 := ifd.FindTagWithName("Orientation")
|
||||
if err1 == nil && len(tags1) > 0 {
|
||||
orientStr, _ = tags1[0].FormatFirst()
|
||||
}
|
||||
if orientStr == "" {
|
||||
tags0, err0 := index.RootIfd.FindTagWithName("Orientation")
|
||||
if err0 == nil && len(tags0) > 0 {
|
||||
orientStr, _ = tags0[0].FormatFirst()
|
||||
}
|
||||
}
|
||||
|
||||
thm, err := ifd.Thumbnail()
|
||||
return thm, wrappedReader, err
|
||||
if err != nil {
|
||||
return nil, wrappedReader, err
|
||||
}
|
||||
|
||||
if orientStr != "" && orientStr != "1" {
|
||||
thm = applyOrientationToPreviewBytes(thm, orientStr)
|
||||
}
|
||||
|
||||
return thm, wrappedReader, nil
|
||||
}
|
||||
|
||||
// CreateThumbnail decodes an image and creates a fixed-size thumbnail.
|
||||
|
||||
@@ -549,3 +549,41 @@ func TestReadImageFileDimensions(t *testing.T) {
|
||||
require.Greater(t, width, 0)
|
||||
require.Greater(t, height, 0)
|
||||
}
|
||||
|
||||
func TestApplyOrientationToPreviewBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generate a 100x60 test JPEG image in memory (landscape)
|
||||
img := image.NewRGBA(image.Rect(0, 0, 100, 60))
|
||||
var buf bytes.Buffer
|
||||
err := jpeg.Encode(&buf, img, nil)
|
||||
require.NoError(t, err)
|
||||
rawBytes := buf.Bytes()
|
||||
|
||||
tests := []struct {
|
||||
orientation string
|
||||
wantWidth int
|
||||
wantHeight int
|
||||
}{
|
||||
{orientation: "1", wantWidth: 100, wantHeight: 60},
|
||||
{orientation: "Horizontal (normal)", wantWidth: 100, wantHeight: 60},
|
||||
{orientation: "8", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "Rotate 270 CW", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "6", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "Rotate 90 CW", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "3", wantWidth: 100, wantHeight: 60},
|
||||
{orientation: "Rotate 180", wantWidth: 100, wantHeight: 60},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.orientation, func(t *testing.T) {
|
||||
rotated := applyOrientationToPreviewBytes(rawBytes, tt.orientation)
|
||||
require.NotEmpty(t, rotated)
|
||||
|
||||
cfg, _, decodeErr := image.DecodeConfig(bytes.NewReader(rotated))
|
||||
require.NoError(t, decodeErr)
|
||||
require.Equal(t, tt.wantWidth, cfg.Width)
|
||||
require.Equal(t, tt.wantHeight, cfg.Height)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
func setupAccessHTTPTest(t *testing.T) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
dbPath := filepath.Join(t.TempDir(), "access-http-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
func setupExtractAccessPolicyTest(t *testing.T) (destDir, sourceRoot, sourceName, username string) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "access.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
|
||||
func setupShareArchiveDownloadTest(t *testing.T) (sourceRoot string, sourceName string, owner *users.User) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
dbPath := filepath.Join(t.TempDir(), "share-dl-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
)
|
||||
|
||||
func setupTestEnv(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
utils.BcryptCost = bcrypt.MinCost
|
||||
dbPath := filepath.Join(t.TempDir(), "test.sqlite")
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -27,6 +28,7 @@ func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
|
||||
// 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) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -59,6 +61,7 @@ func TestGetRemoteIPTrustedProxies(t *testing.T) {
|
||||
|
||||
// Bare IPs (not just CIDRs) are accepted in http.trustedProxies.
|
||||
func TestGetRemoteIPTrustedProxiesBareIP(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -74,6 +77,7 @@ func TestGetRemoteIPTrustedProxiesBareIP(t *testing.T) {
|
||||
|
||||
// Without trustProxyHeaders the XFF chain is ignored entirely.
|
||||
func TestGetRemoteIPIgnoresForwardedForWhenUntrusted(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = false
|
||||
@@ -90,6 +94,7 @@ func TestGetRemoteIPIgnoresForwardedForWhenUntrusted(t *testing.T) {
|
||||
// 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) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -104,8 +109,26 @@ func TestGetRemoteIPIgnoresForwardedHeadersFromUntrustedPeer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRemoteIPUnixSocketHonorsForwardedWithTrustAllLocalPeers(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
settings.Config.Http.Socket = "/var/run/filebrowser.sock"
|
||||
settings.Config.Http.UnixSocketTrustAllLocalPeers = true
|
||||
settings.Config.Http.TrustedProxies = nil
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/", nil)
|
||||
req.RemoteAddr = "@"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.50")
|
||||
if got := GetRemoteIP(req); got != "203.0.113.50" {
|
||||
t.Fatalf("GetRemoteIP = %q, want 203.0.113.50", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With no trusted proxies configured, forwarded headers are never honored.
|
||||
func TestGetRemoteIPIgnoresForwardedHeadersWithoutTrustedProxies(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -121,6 +144,7 @@ func TestGetRemoteIPIgnoresForwardedHeadersWithoutTrustedProxies(t *testing.T) {
|
||||
|
||||
// An IPv4-mapped IPv6 trusted hop in the XFF chain must still be skipped.
|
||||
func TestGetRemoteIPMappedTrustedHop(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -135,6 +159,7 @@ func TestGetRemoteIPMappedTrustedHop(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetScheme(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
t.Run("trusted proto", func(t *testing.T) {
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
@@ -158,6 +183,7 @@ func TestGetScheme(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestSchemeTLSFallback(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
settings.Config.Http.TrustProxyHeaders = false
|
||||
|
||||
req := httptest.NewRequest("GET", "https://example.com/", nil)
|
||||
@@ -168,6 +194,7 @@ func TestRequestSchemeTLSFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestSchemeIgnoresInvalidProto(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
|
||||
@@ -179,6 +206,7 @@ func TestRequestSchemeIgnoresInvalidProto(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestHostTrustProxyHeaders(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
|
||||
@@ -192,6 +220,7 @@ func TestRequestHostTrustProxyHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestSchemeForPublicURL(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http.TrustProxyHeaders
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = orig })
|
||||
|
||||
@@ -225,6 +254,7 @@ func TestRequestSchemeForPublicURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestPageFullURL(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.ExternalUrl = ""
|
||||
|
||||
@@ -237,6 +237,7 @@ func TestResourcePutHandler_SuccessResponseOmitsRealPath(t *testing.T) {
|
||||
|
||||
func setupPutHTTPTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
downloadsPath := filepath.Join(t.TempDir(), "downloads")
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
func setupQuotaHTTPTest(t *testing.T) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
func setupPutQuotaTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
downloadsPath := filepath.Join(t.TempDir(), "downloads")
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
)
|
||||
|
||||
func TestResourcePostHandler_RejectsEscapingParentSymlinkMkdir(t *testing.T) {
|
||||
downloadsPath := setupPutHTTPTest(t)
|
||||
outside := filepath.Join(filepath.Dir(downloadsPath), "outside-mkdir")
|
||||
if err := os.MkdirAll(outside, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(downloadsPath, "evil")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
adminUser, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources?source=Downloads&path=/evil/newdir&isDir=true", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
status, handlerErr := ResourcePostHandler(rec, req, &requestContext{User: &adminUser})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
if handlerErr == nil {
|
||||
t.Fatal("expected mkdir to be forbidden")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "newdir")); !os.IsNotExist(err) {
|
||||
t.Fatal("mkdir escaped outside the source via symlink parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceDeleteHandler_RejectsEscapingIntermediate(t *testing.T) {
|
||||
downloadsPath := setupPutHTTPTest(t)
|
||||
if preview.GetService() == nil {
|
||||
if err := preview.StartPreviewGenerator(1, t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
outsideDir := filepath.Join(filepath.Dir(downloadsPath), "outside-delete")
|
||||
if err := os.MkdirAll(outsideDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
victim := filepath.Join(outsideDir, "victim.txt")
|
||||
if err := os.WriteFile(victim, []byte("keep"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideDir, filepath.Join(downloadsPath, "evil")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
adminUser, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/resources?source=Downloads&path=/evil/victim.txt", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
status, handlerErr := resourceDeleteHandler(rec, req, &requestContext{User: &adminUser})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
if handlerErr == nil {
|
||||
t.Fatal("expected delete to be forbidden")
|
||||
}
|
||||
got, err := os.ReadFile(victim)
|
||||
if err != nil || string(got) != "keep" {
|
||||
t.Fatalf("delete escaped: err=%v content=%q", err, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// webGlobalTestMu serializes tests that mutate process-wide settings.Config or state.
|
||||
var webGlobalTestMu sync.Mutex
|
||||
|
||||
func lockWebGlobals(t testing.TB) {
|
||||
t.Helper()
|
||||
webGlobalTestMu.Lock()
|
||||
t.Cleanup(webGlobalTestMu.Unlock)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
// setupWebDAVTestEnv sets up the test environment with multiple sources and users
|
||||
func setupWebDAVTestEnv(t *testing.T) (string, string) {
|
||||
lockWebGlobals(t)
|
||||
// Create temp directories for test sources
|
||||
tempDir := t.TempDir()
|
||||
source1Path := filepath.Join(tempDir, "source1")
|
||||
|
||||
@@ -1241,6 +1241,61 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
|
||||
return realPath, isDir, nil
|
||||
}
|
||||
|
||||
// realPathCacheJoinedPath builds the filesystem join used in RealPathCache keys.
|
||||
func realPathCacheJoinedPath(sourceRoot, indexPath string) string {
|
||||
seg := utils.CleanIndexPathSegment(indexPath)
|
||||
if seg == "" {
|
||||
return sourceRoot
|
||||
}
|
||||
return filepath.Join(sourceRoot, seg)
|
||||
}
|
||||
|
||||
func realPathCacheIndexAncestors(indexPath string) []string {
|
||||
rel := utils.CleanIndexPathSegment(indexPath)
|
||||
if rel == "" {
|
||||
return []string{"/"}
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
ancestors := []string{"/"}
|
||||
for i := range parts {
|
||||
ancestors = append(ancestors, "/"+strings.Join(parts[:i+1], "/"))
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
// InvalidateRealPathCache drops cached symlink resolutions for indexPath and its
|
||||
// ancestors. scopeBounds should include any caller scope prefixes used with
|
||||
// GetRealPathScoped (e.g. user or share path).
|
||||
func (idx *Index) InvalidateRealPathCache(indexPath string, scopeBounds ...string) {
|
||||
boundSet := map[string]struct{}{"/": {}, "": {}}
|
||||
for _, b := range scopeBounds {
|
||||
b = strings.TrimRight(strings.TrimSpace(b), "/")
|
||||
if b == "" || b == "/" {
|
||||
boundSet["/"] = struct{}{}
|
||||
boundSet[""] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(b, "/") {
|
||||
b = "/" + b
|
||||
}
|
||||
boundSet[b] = struct{}{}
|
||||
}
|
||||
bounds := make([]string, 0, len(boundSet))
|
||||
for b := range boundSet {
|
||||
bounds = append(bounds, b)
|
||||
}
|
||||
for _, ip := range realPathCacheIndexAncestors(indexPath) {
|
||||
joined := realPathCacheJoinedPath(idx.Path, ip)
|
||||
for _, enforceScope := range []bool{false, true} {
|
||||
for _, bound := range bounds {
|
||||
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", enforceScope, bound, joined)
|
||||
RealPathCache.Delete(key)
|
||||
IsDirCache.Delete(key + ":isdir")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetRealPathForMutation resolves an index path for a mutating filesystem
|
||||
// operation (create/overwrite/mkdir/move-into). When the leaf exists it is fully
|
||||
// resolved; otherwise the deepest existing ancestor is resolved and the missing
|
||||
|
||||
@@ -2,6 +2,7 @@ package indexing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -163,6 +164,30 @@ func TestGetRealPathScoped_CacheKeyDoesNotCollideWithUnscoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateRealPathCache_ClearsGetRealPathEntry(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, err := idx.GetRealPath("/user1/foo"); err != nil {
|
||||
t.Fatalf("warm cache: %v", err)
|
||||
}
|
||||
joined := realPathCacheJoinedPath(sourceRoot, "/user1/foo")
|
||||
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", false, "/", joined)
|
||||
if _, ok := RealPathCache.Get(key); !ok {
|
||||
t.Fatalf("expected cache key %q", key)
|
||||
}
|
||||
|
||||
idx.InvalidateRealPathCache("/user1/foo", userScope)
|
||||
if _, ok := RealPathCache.Get(key); ok {
|
||||
t.Fatal("InvalidateRealPathCache should drop unscoped cache entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathForMutation_ExistingInScope(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
@@ -124,7 +124,11 @@ func warnHttpProxyConfig() {
|
||||
}
|
||||
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 Config.Http.UnixSocketTrustAllLocalPeers {
|
||||
logger.Warning("http.unixSocketTrustAllLocalPeers is true: any local process that can connect to the filesystem socket may present X-Forwarded-For and X-Real-IP. Prefer http.unixSocketTrustedUIDs and/or http.unixSocketTrustedGIDs so only peers with allowed Linux credentials can set forwarded headers.")
|
||||
} else {
|
||||
logger.Warning("http.trustProxyHeaders is true on a filesystem Unix socket, but http.unixSocketTrustedUIDs and http.unixSocketTrustedGIDs are empty; forwarded client headers from socket peers are ignored. List allowed peer UIDs and/or GIDs (Linux SO_PEERCRED), or set http.unixSocketTrustAllLocalPeers: true only if you accept legacy trust-all behavior.")
|
||||
}
|
||||
}
|
||||
if !Env.IsDevMode {
|
||||
if u := Config.Http.ExternalUrl; u != "" && strings.HasPrefix(strings.ToLower(u), "http://") {
|
||||
|
||||
@@ -37,11 +37,14 @@ type Http struct {
|
||||
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
|
||||
// present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
|
||||
// unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
|
||||
// every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
|
||||
// forwarded headers.
|
||||
UnixSocketTrustedUIDs []int `json:"unixSocketTrustedUIDs"`
|
||||
UnixSocketTrustedGIDs []int `json:"unixSocketTrustedGIDs"`
|
||||
UnixSocketTrustAllLocalPeers bool `json:"unixSocketTrustAllLocalPeers"` // default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty
|
||||
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).
|
||||
}
|
||||
|
||||
@@ -113,10 +113,11 @@ func ipMatchesPrefixes(ip string, prefixes []netip.Prefix) bool {
|
||||
// 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.
|
||||
// When http.socket is set the server listens only on that Unix socket. Abstract
|
||||
// sockets ("@name") have no filesystem permissions and their peers are never trusted.
|
||||
// On filesystem sockets, when either allowlist is set, only peers whose Linux SO_PEERCRED
|
||||
// UID or GID matches may present forwarded headers. When both allowlists are empty,
|
||||
// peers are trusted only if http.unixSocketTrustAllLocalPeers is true (legacy opt-in).
|
||||
func IsTrustedProxyPeer(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
@@ -125,6 +126,9 @@ func IsTrustedProxyPeer(r *http.Request) bool {
|
||||
if strings.HasPrefix(socket, "@") {
|
||||
return false
|
||||
}
|
||||
if len(Config.Http.UnixSocketTrustedUIDs) == 0 && len(Config.Http.UnixSocketTrustedGIDs) == 0 {
|
||||
return Config.Http.UnixSocketTrustAllLocalPeers
|
||||
}
|
||||
cred, ok := UnixPeerFromContext(r.Context())
|
||||
if !ok {
|
||||
return false
|
||||
|
||||
@@ -132,7 +132,6 @@ func TestIsTrustedProxyPeer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// With http.socket set, forwarded headers require matching unixSocketTrustedUIDs/GIDs.
|
||||
func TestIsTrustedProxyPeerUnixSocket(t *testing.T) {
|
||||
resetTrustedProxyCache()
|
||||
Config.Http.TrustedProxies = nil
|
||||
@@ -141,15 +140,23 @@ func TestIsTrustedProxyPeerUnixSocket(t *testing.T) {
|
||||
Config.Http.Socket = ""
|
||||
Config.Http.UnixSocketTrustedUIDs = nil
|
||||
Config.Http.UnixSocketTrustedGIDs = nil
|
||||
Config.Http.UnixSocketTrustAllLocalPeers = false
|
||||
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)
|
||||
}
|
||||
if IsTrustedProxyPeer(&http.Request{RemoteAddr: "@"}) {
|
||||
t.Fatal("filesystem unix socket with empty allowlists must not trust forwarded headers without opt-in")
|
||||
}
|
||||
Config.Http.UnixSocketTrustAllLocalPeers = true
|
||||
if !IsTrustedProxyPeer(&http.Request{RemoteAddr: "@"}) {
|
||||
t.Fatal("unixSocketTrustAllLocalPeers should restore legacy trust-all behavior")
|
||||
}
|
||||
Config.Http.UnixSocketTrustAllLocalPeers = false
|
||||
|
||||
Config.Http.UnixSocketTrustedUIDs = []int{33}
|
||||
reqNoCred := &http.Request{RemoteAddr: "@"}
|
||||
if IsTrustedProxyPeer(reqNoCred) {
|
||||
t.Fatal("with allowlists set, peer without credentials must not be trusted")
|
||||
}
|
||||
req := (&http.Request{}).WithContext(ContextWithUnixPeerForTest(context.Background(), 33, 1000))
|
||||
if !IsTrustedProxyPeer(req) {
|
||||
t.Fatal("peer with allowed UID should be trusted")
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var unixListenUmaskMu sync.Mutex
|
||||
|
||||
type unixPeerCredKey struct{}
|
||||
|
||||
// UnixPeerCredential holds the peer process identity for a Unix domain connection.
|
||||
@@ -69,14 +72,15 @@ func ListenUnixFilesystem(socketPath string) (net.Listener, error) {
|
||||
}
|
||||
setGroup = true
|
||||
}
|
||||
unixListenUmaskMu.Lock()
|
||||
oldMask := unixUmask(0o077)
|
||||
defer unixUmask(oldMask)
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
unixUmask(oldMask)
|
||||
unixListenUmaskMu.Unlock()
|
||||
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()
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package settings
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSocketFileModeDefaults(t *testing.T) {
|
||||
mode, err := parseSocketFileMode("")
|
||||
@@ -51,3 +56,29 @@ func TestUnixPeerAllowed(t *testing.T) {
|
||||
t.Fatal("empty allowlists must deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenUnixFilesystemSetsModeWithRestrictiveUmask(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("filesystem unix sockets are not used on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
socketPath := filepath.Join(dir, "fb.sock")
|
||||
orig := Config.Http
|
||||
Config.Http.SocketFileMode = ""
|
||||
t.Cleanup(func() {
|
||||
Config.Http = orig
|
||||
_ = os.Remove(socketPath)
|
||||
})
|
||||
ln, err := ListenUnixFilesystem(socketPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
st, err := os.Stat(socketPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Mode().Perm() != 0o660 {
|
||||
t.Fatalf("socket perm = %o, want 0660", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build !unix && !windows
|
||||
|
||||
package settings
|
||||
|
||||
func unixUmask(_ int) int {
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !unix
|
||||
|
||||
package settings
|
||||
|
||||
func unixUmask(mask int) int {
|
||||
return 0
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package settings
|
||||
|
||||
func unixUmask(_ int) int {
|
||||
return 0
|
||||
}
|
||||
@@ -6040,6 +6040,10 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"unixSocketTrustAllLocalPeers": {
|
||||
"description": "default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty",
|
||||
"type": "boolean"
|
||||
},
|
||||
"unixSocketTrustedGIDs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -6047,7 +6051,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"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, peers are not trusted\nunless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts\nevery peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust\nforwarded headers.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
|
||||
@@ -6029,6 +6029,10 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"unixSocketTrustAllLocalPeers": {
|
||||
"description": "default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty",
|
||||
"type": "boolean"
|
||||
},
|
||||
"unixSocketTrustedGIDs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -6036,7 +6040,7 @@
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"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, peers are not trusted\nunless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts\nevery peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust\nforwarded headers.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
|
||||
@@ -500,6 +500,10 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
unixSocketTrustAllLocalPeers:
|
||||
description: 'default false: set true to trust forwarded headers from any
|
||||
local filesystem socket peer when allowlists are empty'
|
||||
type: boolean
|
||||
unixSocketTrustedGIDs:
|
||||
items:
|
||||
type: integer
|
||||
@@ -507,8 +511,10 @@ definitions:
|
||||
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.
|
||||
present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
|
||||
unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
|
||||
every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
|
||||
forwarded headers.
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
22
|
||||
22.22.2
|
||||
Generated
+684
-1436
File diff suppressed because it is too large.
Load diff
+15
-14
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": ">=9.0.0",
|
||||
"node": "^22.13.0 || ^24.0.0 || >=26.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "mkdir -p ../backend/internal/web/embed && rm -rf ../backend/internal/web/embed/* && vite build && cp -r ../backend/internal/web/dist/* ../backend/internal/web/embed",
|
||||
@@ -30,34 +30,34 @@
|
||||
"test": "vitest run "
|
||||
},
|
||||
"dependencies": {
|
||||
"@likecoin/epub-ts": "^0.7.2",
|
||||
"@likecoin/epub-ts": "^0.7.3",
|
||||
"@onlyoffice/document-editor-vue": "^1.7.0",
|
||||
"ace-builds": "^1.45.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"ace-builds": "^1.45.2",
|
||||
"chart.js": "^4.6.1",
|
||||
"dompurify": "^3.4.17",
|
||||
"highlight.js": "^11.12.0",
|
||||
"js-yaml": "^5.5.0",
|
||||
"mammoth": "^1.13.0",
|
||||
"marked": "^18.1.1",
|
||||
"marked": "^18.2.0",
|
||||
"plyr": "^3.8.5",
|
||||
"qrcode.vue": "^3.11.0",
|
||||
"temml": "^0.14.0",
|
||||
"three": "^0.186.1",
|
||||
"vue": "^3.5.44",
|
||||
"vue-i18n": "^11.4.16",
|
||||
"vue-router": "^5.4.0"
|
||||
"three": "^0.187.0",
|
||||
"vue": "^3.5.45",
|
||||
"vue-i18n": "^11.4.19",
|
||||
"vue-router": "^5.5.0"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.6",
|
||||
"minimatch": "^11.0.8",
|
||||
"@xmldom/xmldom": "^0.8.15",
|
||||
"mammoth": {
|
||||
"argparse": "^2.0.1"
|
||||
"argparse": "^3.0.4"
|
||||
},
|
||||
"@intlify/vue-i18n-extensions": {
|
||||
"vue-i18n": "$vue-i18n"
|
||||
},
|
||||
"@intlify/eslint-plugin-vue-i18n": {
|
||||
"glob": "^13.0.0"
|
||||
"glob": "^14.0.6"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -69,12 +69,13 @@
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"deepl-node": "^1.28.1",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-vue": "^10.11.1",
|
||||
"fs-extra": "^11.4.1",
|
||||
"jsdom": "^30.1.3",
|
||||
"stylelint": "^17.16.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"stylelint": "^18.0.0",
|
||||
"stylelint-config-standard": "^41.0.0",
|
||||
"stylelint-config-standard-vue": "^2.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.4",
|
||||
|
||||
@@ -318,9 +318,12 @@ http:
|
||||
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.
|
||||
# present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
|
||||
# unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
|
||||
# every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
|
||||
# forwarded headers.
|
||||
unixSocketTrustedGIDs:
|
||||
unixSocketTrustAllLocalPeers: false # default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty
|
||||
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).
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchFiles, state } = vi.hoisted(() => ({
|
||||
fetchFiles: vi.fn(),
|
||||
state: { req: {}, prompts: [], selected: [], sources: { info: { data: {} } } },
|
||||
}));
|
||||
vi.mock("@/api", () => ({ resourcesApi: { fetchFiles } }));
|
||||
vi.mock("@/store", () => ({
|
||||
state,
|
||||
mutations: {},
|
||||
getters: {
|
||||
isShare: () => false,
|
||||
isMobile: () => false,
|
||||
currentPromptName: () => null,
|
||||
pickerSorting: () => ({ by: "name", asc: true }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/utils", async () => {
|
||||
const { removeLastDir } = await import("@/utils/url.js");
|
||||
return { url: { removeLastDir } };
|
||||
});
|
||||
vi.mock("@/router/index.ts", () => ({ router: {} }));
|
||||
vi.mock("@/components/files/ListingHeader.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/files/ListingItem.vue", () => ({
|
||||
default: {
|
||||
props: ["name"],
|
||||
render() {
|
||||
return h("button", {}, this.name);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import FileList from "./FileList.vue";
|
||||
|
||||
let app, root;
|
||||
const selection = vi.fn();
|
||||
function mount(items = [], path = "/child/") {
|
||||
state.req = { source: "data", path, items };
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(FileList, { hidePathChrome: true, "onUpdate:selected": selection });
|
||||
app.config.globalProperties.$t = (key) => key;
|
||||
app.mount(root);
|
||||
}
|
||||
|
||||
function row(name) {
|
||||
return Array.from(root.querySelectorAll("button")).find((button) => button.textContent === name);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root?.remove();
|
||||
fetchFiles.mockReset();
|
||||
selection.mockReset();
|
||||
});
|
||||
|
||||
describe("file picker parent navigation", () => {
|
||||
it("loads the parent and shows sibling folders after one click on '..'", async () => {
|
||||
mount();
|
||||
fetchFiles.mockResolvedValue({
|
||||
source: "data",
|
||||
items: [{ name: "sibling", path: "/sibling/", type: "directory" }],
|
||||
});
|
||||
await nextTick();
|
||||
row("..").click();
|
||||
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/", source: "data" }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/");
|
||||
await vi.waitFor(() => expect(row("sibling")).toBeDefined());
|
||||
});
|
||||
|
||||
it("keeps single-click selection and double-click navigation for ordinary folders", async () => {
|
||||
mount([{ name: "nested", path: "/child/nested/", type: "directory" }]);
|
||||
fetchFiles.mockResolvedValue({ source: "data", items: [] });
|
||||
await nextTick();
|
||||
row("nested").click();
|
||||
expect(fetchFiles).not.toHaveBeenCalled();
|
||||
row("nested").dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/child/nested/");
|
||||
});
|
||||
|
||||
it("does not fetch the parent again when the same row receives a double-click", async () => {
|
||||
mount();
|
||||
fetchFiles.mockResolvedValue({ source: "data", items: [] });
|
||||
await nextTick();
|
||||
const parent = row("..");
|
||||
parent.click();
|
||||
parent.click();
|
||||
parent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/");
|
||||
});
|
||||
|
||||
it("ignores the rest of a double-click on a replacement parent row", async () => {
|
||||
mount([], "/parent/child/");
|
||||
fetchFiles.mockResolvedValue({ source: "data", items: [] });
|
||||
await nextTick();
|
||||
const firstParent = row("..");
|
||||
firstParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||||
await vi.waitFor(() => {
|
||||
expect(row("..")).toBeDefined();
|
||||
expect(row("..")).not.toBe(firstParent);
|
||||
});
|
||||
const nextParent = row("..");
|
||||
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 2 }));
|
||||
nextParent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, detail: 2 }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/parent/");
|
||||
|
||||
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||||
expect(fetchFiles).toHaveBeenLastCalledWith("data", "/");
|
||||
expect(fetchFiles).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -542,6 +542,7 @@ export default {
|
||||
this.next(event);
|
||||
}
|
||||
},
|
||||
/** @param {MouseEvent} event */
|
||||
handleItemClick(item, _index, event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -552,7 +553,6 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Browse mode: single click selects only, emit selected, do not navigate
|
||||
const syntheticEvent = {
|
||||
currentTarget: {
|
||||
dataset: {
|
||||
@@ -562,11 +562,19 @@ export default {
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
};
|
||||
if (item.name === "..") {
|
||||
if (event.detail > 1 || this.path === item.path) return;
|
||||
this.select(syntheticEvent);
|
||||
this.next(syntheticEvent);
|
||||
return;
|
||||
}
|
||||
this.select(syntheticEvent);
|
||||
},
|
||||
/** @param {MouseEvent} event */
|
||||
handleItemDblClick(item, _index, event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (item.name === ".." && (!this.fileList || this.path === item.path)) return;
|
||||
|
||||
const syntheticEvent = {
|
||||
currentTarget: {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createUserEditSession, destroyUserEditSession, getUserEditSession } from "@/utils/userEditSession.js";
|
||||
import { applySectionsToFlatUser, sectionsFromFlatUser } from "@/utils/userProfileSections.js";
|
||||
|
||||
const { fetchURL, fetchJSON, state, mutations } = vi.hoisted(() => ({
|
||||
fetchURL: vi.fn(),
|
||||
fetchJSON: vi.fn(),
|
||||
state: { user: { username: "admin" }, enforcedUserDefaults: {} },
|
||||
mutations: { closeTopPrompt: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/api", async () => ({
|
||||
usersApi: await vi.importActual("@/api/users.js"),
|
||||
settingsApi: {},
|
||||
authApi: {},
|
||||
accessApi: {},
|
||||
}));
|
||||
vi.mock("@/api/utils.ts", () => ({ fetchURL, fetchJSON }));
|
||||
vi.mock("@/router/index.ts", () => ({ router: {} }));
|
||||
vi.mock("@/store", () => ({ state, mutations }));
|
||||
vi.mock("@/store/state.ts", () => ({ state }));
|
||||
vi.mock("@/store/mutations.ts", () => ({ mutations }));
|
||||
vi.mock("@/store/eventBus", () => ({ eventBus: { emit: vi.fn() } }));
|
||||
vi.mock("@/notify", () => ({ notify: { showSuccessToast: vi.fn(), showError: vi.fn() } }));
|
||||
vi.mock("@/i18n/index.ts", () => ({ default: { global: { t: (key) => key } } }));
|
||||
vi.mock("@/utils/auth", () => ({ validateLogin: vi.fn() }));
|
||||
vi.mock("@/components/settings/UserProfilePreferences.vue", () => ({ default: { render: () => null } }));
|
||||
|
||||
import { get } from "@/api/users.js";
|
||||
import UserEdit from "./UserEdit.vue";
|
||||
import UserEditPreferences from "./UserEditPreferences.vue";
|
||||
|
||||
let app, root;
|
||||
function openPreferences(user) {
|
||||
createUserEditSession({ user, profileUser: { ...user } });
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(UserEditPreferences);
|
||||
app.config.globalProperties.$t = (key) => key;
|
||||
app.mount(root);
|
||||
}
|
||||
|
||||
function passwordChangeToggle() {
|
||||
return root.querySelector('input[aria-label="settings.requirePasswordChange"]');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = undefined;
|
||||
root?.remove();
|
||||
destroyUserEditSession();
|
||||
state.enforcedUserDefaults = {};
|
||||
fetchURL.mockReset();
|
||||
fetchJSON.mockReset();
|
||||
});
|
||||
|
||||
describe("password changes in user edit preferences", () => {
|
||||
it.each([false, true])("passes the changed requirement to the parent save (initially %s)", async (initial) => {
|
||||
const user = { username: "user", loginMethod: "password", requirePasswordChange: initial };
|
||||
applySectionsToFlatUser(user, sectionsFromFlatUser(user));
|
||||
const editor = {
|
||||
...UserEdit.data(),
|
||||
user: { ...user },
|
||||
profileUser: { ...user },
|
||||
loaded: true,
|
||||
groupsReady: true,
|
||||
isNew: false,
|
||||
enforcedAccount: {},
|
||||
enforcedAccountPermissions: {},
|
||||
enforcedPreferences: {},
|
||||
$t: (key) => key,
|
||||
};
|
||||
for (const [name, method] of Object.entries(UserEdit.methods)) {
|
||||
editor[name] = method.bind(editor);
|
||||
}
|
||||
editor.saveGroups = vi.fn();
|
||||
editor.syncEditAccountForm();
|
||||
editor.originalSnapshot = editor.buildEditableSnapshot();
|
||||
|
||||
openPreferences(user);
|
||||
await nextTick();
|
||||
const toggle = passwordChangeToggle();
|
||||
expect(toggle?.checked).toBe(initial);
|
||||
toggle.click();
|
||||
await nextTick();
|
||||
expect(getUserEditSession().user.requirePasswordChange).toBe(!initial);
|
||||
expect(getUserEditSession().profileUser.requirePasswordChange).toBe(!initial);
|
||||
|
||||
await editor.save({ preventDefault: vi.fn() });
|
||||
expect(fetchURL).toHaveBeenCalledTimes(1);
|
||||
const [url, request] = fetchURL.mock.calls[0];
|
||||
expect(url).toMatch(/\/api\/users\?username=user$/);
|
||||
expect(request.method).toBe("PATCH");
|
||||
const body = JSON.parse(request.body);
|
||||
expect(body).toEqual({
|
||||
which: ["requirePasswordChange"],
|
||||
data: { requirePasswordChange: !initial },
|
||||
});
|
||||
|
||||
app.unmount();
|
||||
root.remove();
|
||||
fetchJSON.mockResolvedValue({ ...user, ...body.data });
|
||||
openPreferences(await get(user.username));
|
||||
await nextTick();
|
||||
expect(passwordChangeToggle()?.checked).toBe(!initial);
|
||||
});
|
||||
|
||||
it("hides the toggle for externally authenticated users", async () => {
|
||||
openPreferences({ username: "user", loginMethod: "oidc" });
|
||||
await nextTick();
|
||||
expect(passwordChangeToggle()).toBeNull();
|
||||
});
|
||||
|
||||
it("disables an enforced requirement", async () => {
|
||||
state.enforcedUserDefaults = { account: { requirePasswordChange: true } };
|
||||
openPreferences({ username: "user", loginMethod: "password", requirePasswordChange: true });
|
||||
await nextTick();
|
||||
expect(passwordChangeToggle()?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
:account="editAccount"
|
||||
:enforced="enforcedAccount"
|
||||
:enforced-permissions="enforcedAccountPermissions"
|
||||
:show-require-password-change="session.user?.loginMethod === 'password'"
|
||||
respect-enforced-policy
|
||||
@account-change="onEditAccountChange"
|
||||
/>
|
||||
@@ -62,6 +63,7 @@ export default {
|
||||
sessionUnsubscribe: null,
|
||||
editAccount: {
|
||||
lockPassword: false,
|
||||
requirePasswordChange: false,
|
||||
disableSettings: false,
|
||||
disableUpdateNotifications: false,
|
||||
showAdvancedProfile: false,
|
||||
@@ -114,6 +116,7 @@ export default {
|
||||
const user = this.session?.user || {};
|
||||
const permissions = user.permissions || {};
|
||||
this.editAccount.lockPassword = !!user.lockPassword;
|
||||
this.editAccount.requirePasswordChange = !!user.requirePasswordChange;
|
||||
this.editAccount.disableSettings = !!user.disableSettings;
|
||||
this.editAccount.disableUpdateNotifications = !!user.disableUpdateNotifications;
|
||||
this.editAccount.showAdvancedProfile = !!user.showAdvancedProfile;
|
||||
@@ -131,6 +134,7 @@ export default {
|
||||
const user = {
|
||||
...this.session.user,
|
||||
lockPassword: this.editAccount.lockPassword,
|
||||
requirePasswordChange: this.editAccount.requirePasswordChange,
|
||||
disableSettings: this.editAccount.disableSettings,
|
||||
disableUpdateNotifications: this.editAccount.disableUpdateNotifications,
|
||||
showAdvancedProfile: this.editAccount.showAdvancedProfile,
|
||||
@@ -144,6 +148,7 @@ export default {
|
||||
sections.account = {
|
||||
...(sections.account || {}),
|
||||
lockPassword: this.editAccount.lockPassword,
|
||||
requirePasswordChange: this.editAccount.requirePasswordChange,
|
||||
disableSettings: this.editAccount.disableSettings,
|
||||
disableUpdateNotifications: this.editAccount.disableUpdateNotifications,
|
||||
showAdvancedProfile: this.editAccount.showAdvancedProfile,
|
||||
|
||||
@@ -30,6 +30,21 @@
|
||||
aria-label="Edit Links"
|
||||
@click="openSidebarLinksPrompt" class="material-symbols action">edit</i>
|
||||
</div>
|
||||
<!-- Source switcher only in navigation mode and when multiple sources exist (hidden in shares).
|
||||
Kept outside the scrollable content area so it does not scroll horizontally with the folder tree. -->
|
||||
<div
|
||||
v-if="mode === 'navigation' && !isShare && sourceNames.length > 1"
|
||||
class="source-card-container"
|
||||
ref="sourceCardContainer"
|
||||
>
|
||||
<ExpandDropdown
|
||||
class="sidebar-source-switcher"
|
||||
:model-value="activeSource"
|
||||
:options="sourceDropdownOptions"
|
||||
:aria-label="$t('general.source')"
|
||||
@update:model-value="navigateToSource"
|
||||
/>
|
||||
</div>
|
||||
<!-- Scrollable Content Area -->
|
||||
<div class="sidebar-links-content">
|
||||
<!-- Links Mode -->
|
||||
@@ -131,20 +146,6 @@
|
||||
</template>
|
||||
<!-- Navigation Mode -->
|
||||
<template v-else>
|
||||
<!-- Source switcher only when multiple sources (hidden in shares) -->
|
||||
<div
|
||||
v-if="!isShare && sourceNames.length > 1"
|
||||
class="source-card-container"
|
||||
ref="sourceCardContainer"
|
||||
>
|
||||
<ExpandDropdown
|
||||
class="sidebar-source-switcher"
|
||||
:model-value="activeSource"
|
||||
:options="sourceDropdownOptions"
|
||||
:aria-label="$t('general.source')"
|
||||
@update:model-value="navigateToSource"
|
||||
/>
|
||||
</div>
|
||||
<FileTree
|
||||
:currentPath="currentPath"
|
||||
:currentSource="currentSource"
|
||||
@@ -897,11 +898,17 @@ a.sidebar-link-button {
|
||||
|
||||
.source-card-container {
|
||||
position: relative;
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
/* When the source switcher sits above the scroll area, the content keeps its tree flush under the divider. */
|
||||
.source-card-container + .sidebar-links-content {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sidebar-source-switcher {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
+264
-264
File diff suppressed because it is too large.
Load diff
@@ -111,6 +111,22 @@ test("2x copy from listing to new folder", async({ page, checkForErrors }) => {
|
||||
await checkForNotification(page, "Files copied successfully!");
|
||||
await page.goto("/files/playwright%20%2B%20files/myfolder/newfolder/");
|
||||
await expect(page).toHaveTitle(/.* - newfolder/);
|
||||
await page.locator('a[aria-label="copyme.txt"]').click({ button: "right" });
|
||||
await page.locator('button[aria-label="Copy file"]').click();
|
||||
const parentRow = page.locator('div[aria-label="copy-prompt"] .listing-item[aria-label=".."]');
|
||||
const originalParentRow = await parentRow.elementHandle();
|
||||
await parentRow.click();
|
||||
await expect(copyDestLabel(page)).toHaveText(`/myfolder/ (${GENERAL_COPY_SOURCE})`);
|
||||
await expect(parentRow).toBeVisible();
|
||||
expect(await originalParentRow?.evaluate((row) => row.isConnected)).toBe(false);
|
||||
await parentRow.evaluate((row) => {
|
||||
for (const type of ["click", "dblclick"]) {
|
||||
row.dispatchEvent(new MouseEvent(type, { bubbles: true, detail: 2 }));
|
||||
}
|
||||
});
|
||||
await expect(copyDestLabel(page)).toHaveText(`/myfolder/ (${GENERAL_COPY_SOURCE})`);
|
||||
await parentRow.click();
|
||||
await expect(copyDestLabel(page)).toHaveText(`/ (${GENERAL_COPY_SOURCE})`);
|
||||
checkForErrors();
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../test-setup.ts";
|
||||
|
||||
/*
|
||||
* Regression test for issue #3351.
|
||||
*
|
||||
* Defect: when the folder tree in the sidebar overflows horizontally, the
|
||||
* source path selector (rendered inside the same scroll container,
|
||||
* `.sidebar-links-content`) moves with the tree instead of staying fixed.
|
||||
*
|
||||
* The no-auth configuration already provides two enabled sources (`exclude`
|
||||
* and `include`), so the path selector renders. The nested fixture is created
|
||||
* through the application's own API before each test and removed afterwards.
|
||||
*/
|
||||
|
||||
// Enabled source from the no-auth configuration (see _docker/src/noauth/backend/config.yaml).
|
||||
const SOURCE = "exclude";
|
||||
|
||||
// Fixture location inside the source. The exact name is only used by this test.
|
||||
const FIXTURE_ROOT_NAME = "sidebar-scroll-3351";
|
||||
const FIXTURE_ROOT = `/${FIXTURE_ROOT_NAME}/`;
|
||||
const LEVEL_NAMES = [
|
||||
"level-1-sidebar-width-overflow-probe-directory",
|
||||
"level-2-nested-directory-with-an-extremely-long-name",
|
||||
"level-3-deep-directory-name-that-keeps-going-for-width",
|
||||
"level-4-directory-used-to-widen-the-tree-past-sidebar",
|
||||
"level-5-directory-with-a-very-long-name-to-force-scroll",
|
||||
"level-6-directory-name-long-enough-to-overflow-content",
|
||||
"level-7-leaf-directory-with-the-longest-name-in-tree-x",
|
||||
];
|
||||
const DEEPEST_LEVEL_NAME = LEVEL_NAMES[LEVEL_NAMES.length - 1];
|
||||
const DEEP_PATH = `${FIXTURE_ROOT}${LEVEL_NAMES.join("/")}/`;
|
||||
|
||||
// API paths are absolute, so they resolve under the configured baseURL (the app is served under /files/).
|
||||
const RESOURCES_API = "/files/api/resources";
|
||||
|
||||
const MIN_OVERFLOW_PX = 100;
|
||||
const MIN_TREE_SCROLL_PX = 50;
|
||||
const STATIONARY_TOLERANCE_PX = 1;
|
||||
const MIN_VERTICAL_SCROLL_PX = 50;
|
||||
|
||||
/** Removes the fixture directory tree. A missing fixture (404) is not an error. */
|
||||
async function deleteFixture(page: Page): Promise<void> {
|
||||
const response = await page.request.delete(RESOURCES_API, {
|
||||
params: { path: FIXTURE_ROOT, source: SOURCE },
|
||||
});
|
||||
if (response.status() !== 404 && !response.ok()) {
|
||||
throw new Error(`could not delete fixture ${FIXTURE_ROOT}: HTTP ${response.status()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates the nested directories one level at a time, parents first. */
|
||||
async function createFixture(page: Page): Promise<void> {
|
||||
let path = "/";
|
||||
for (const name of [FIXTURE_ROOT_NAME, ...LEVEL_NAMES]) {
|
||||
path = `${path}${name}/`;
|
||||
const response = await page.request.post(RESOURCES_API, {
|
||||
params: { path, source: SOURCE, isDir: "true" },
|
||||
});
|
||||
if (!response.ok()) {
|
||||
throw new Error(`could not create fixture directory ${path}: HTTP ${response.status()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function requireBox(locator: Locator, name: string) {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`${name} has no bounding box (not rendered or not visible)`);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the sidebar's scroll position and a tree row's position stop changing.
|
||||
* The sidebar scrolls on its own after the folder tree first renders (it brings the current
|
||||
* folder into view), so baselines must be taken after that movement ends.
|
||||
*/
|
||||
async function waitForSidebarToSettle(content: Locator, row: Locator) {
|
||||
let previous: string | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const box = await requireBox(row, "deepest tree row");
|
||||
const scrollLeft = await content.evaluate((el) => el.scrollLeft);
|
||||
const current = `${scrollLeft}|${box.x}`;
|
||||
const settled = current === previous;
|
||||
previous = current;
|
||||
return settled;
|
||||
},
|
||||
{ message: "sidebar scroll position and tree row should settle before measuring" },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
// Viewport used to reproduce the overflow (sidebar width 288px at this size).
|
||||
test.use({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Remove any fixture left behind by an interrupted run, then create a fresh one.
|
||||
await deleteFixture(page);
|
||||
await createFixture(page);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteFixture(page);
|
||||
});
|
||||
|
||||
test("sidebar path selector stays stationary while the folder tree scrolls horizontally", async ({ page, checkForErrors }) => {
|
||||
const sidebarContent = page.locator(".sidebar-links-content");
|
||||
const pathSelector = page.locator(".sidebar-source-switcher");
|
||||
const homeButton = page.getByLabel("Navigate Home");
|
||||
const fileTree = page.locator(".file-tree-container");
|
||||
const deepestRow = page.locator(".tree-node", { hasText: DEEPEST_LEVEL_NAME });
|
||||
const sidebar = page.locator(".sidebar-links");
|
||||
const modeToggle = page.locator(".sidebar-mode-toggle");
|
||||
const editButton = page.locator('.sidebar-links-header i[aria-label="Edit Links"]');
|
||||
const levelOneRow = page.locator(".tree-node", { hasText: LEVEL_NAMES[0] });
|
||||
const levelTwoRow = page.locator(".tree-node", { hasText: LEVEL_NAMES[1] });
|
||||
|
||||
await page.goto(`/files/${SOURCE}${DEEP_PATH}`);
|
||||
|
||||
// The sidebar opens in links mode; the folder tree renders only in navigation mode.
|
||||
await page.locator(".sidebar-mode-toggle label.switch").click();
|
||||
await expect(fileTree).toBeVisible();
|
||||
await expect(deepestRow).toBeVisible();
|
||||
await waitForSidebarToSettle(sidebarContent, deepestRow);
|
||||
|
||||
// Two enabled sources must render the path selector. Fail if it is absent.
|
||||
await expect(pathSelector, "path selector must render with two enabled sources").toBeVisible();
|
||||
|
||||
// Overflow precondition: the scroll container that holds the tree must overflow by >= 100px.
|
||||
const overflowX = await sidebarContent.evaluate((el) => getComputedStyle(el).overflowX);
|
||||
expect(overflowX, ".sidebar-links-content must be a horizontal scroll container").toMatch(/auto|scroll/);
|
||||
const overflowPx = await sidebarContent.evaluate((el) => el.scrollWidth - el.clientWidth);
|
||||
expect(overflowPx, "horizontal overflow precondition").toBeGreaterThanOrEqual(MIN_OVERFLOW_PX);
|
||||
|
||||
// Deterministic start: measure relative to the current scrollLeft, which may be nonzero.
|
||||
const scrollLeftBefore = await sidebarContent.evaluate((el) => el.scrollLeft);
|
||||
const remainingScrollPx = await sidebarContent.evaluate((el) => el.scrollWidth - el.clientWidth - el.scrollLeft);
|
||||
expect(remainingScrollPx, "container must have room to scroll at least 50px from its starting position")
|
||||
.toBeGreaterThanOrEqual(MIN_TREE_SCROLL_PX);
|
||||
|
||||
const homeBefore = await requireBox(homeButton, "home button");
|
||||
const pathBefore = await requireBox(pathSelector, "path selector");
|
||||
const treeRowBefore = await requireBox(deepestRow, "deepest tree row");
|
||||
const modeToggleBefore = await requireBox(modeToggle, "mode toggle");
|
||||
const editButtonBefore = await requireBox(editButton, "edit links button");
|
||||
|
||||
// Scroll the actual overflowing container horizontally with a wheel gesture over its content.
|
||||
const contentBox = await requireBox(sidebarContent, ".sidebar-links-content");
|
||||
await page.mouse.move(contentBox.x + contentBox.width / 2, contentBox.y + contentBox.height / 2);
|
||||
await page.mouse.wheel(200, 0);
|
||||
|
||||
await expect
|
||||
.poll(() => sidebarContent.evaluate((el) => el.scrollLeft), { message: "container should scroll horizontally" })
|
||||
.toBeGreaterThanOrEqual(scrollLeftBefore + MIN_TREE_SCROLL_PX);
|
||||
|
||||
// The folder tree itself must move horizontally.
|
||||
const treeRowAfter = await requireBox(deepestRow, "deepest tree row");
|
||||
const treeMovedLeftPx = treeRowBefore.x - treeRowAfter.x;
|
||||
expect(treeMovedLeftPx, "folder tree rows must move left by at least 50px").toBeGreaterThanOrEqual(MIN_TREE_SCROLL_PX);
|
||||
|
||||
// Preservation check: the home button must stay put while the tree scrolls.
|
||||
const homeAfter = await requireBox(homeButton, "home button");
|
||||
expect(Math.abs(homeAfter.x - homeBefore.x), "home button must not move").toBeLessThanOrEqual(STATIONARY_TOLERANCE_PX);
|
||||
|
||||
// Regression assertion: the path selector must stay stationary. Fails on the pre-fix code.
|
||||
const pathAfter = await requireBox(pathSelector, "path selector");
|
||||
expect(
|
||||
Math.abs(pathAfter.x - pathBefore.x),
|
||||
"path selector moved horizontally with the folder tree (issue #3351)",
|
||||
).toBeLessThanOrEqual(STATIONARY_TOLERANCE_PX);
|
||||
|
||||
// Mode toggle and edit links button must also stay put.
|
||||
const modeToggleAfter = await requireBox(modeToggle, "mode toggle");
|
||||
expect(Math.abs(modeToggleAfter.x - modeToggleBefore.x), "mode toggle must not move").toBeLessThanOrEqual(
|
||||
STATIONARY_TOLERANCE_PX,
|
||||
);
|
||||
const editButtonAfter = await requireBox(editButton, "edit links button");
|
||||
expect(Math.abs(editButtonAfter.x - editButtonBefore.x), "edit links button must not move").toBeLessThanOrEqual(
|
||||
STATIONARY_TOLERANCE_PX,
|
||||
);
|
||||
|
||||
// Header controls and the path selector must stay inside the sidebar's visible bounds.
|
||||
const sidebarAfter = await requireBox(sidebar, ".sidebar-links");
|
||||
const controlsAfter = [
|
||||
["home button", homeAfter],
|
||||
["mode toggle", modeToggleAfter],
|
||||
["edit links button", editButtonAfter],
|
||||
["path selector", pathAfter],
|
||||
] as const;
|
||||
for (const [name, box] of controlsAfter) {
|
||||
expect(box.x, `${name} must not be clipped on the left`).toBeGreaterThanOrEqual(
|
||||
sidebarAfter.x - STATIONARY_TOLERANCE_PX,
|
||||
);
|
||||
expect(box.x + box.width, `${name} must not be clipped on the right`).toBeLessThanOrEqual(
|
||||
sidebarAfter.x + sidebarAfter.width + STATIONARY_TOLERANCE_PX,
|
||||
);
|
||||
}
|
||||
|
||||
// The active folder keeps its highlight after horizontal scrolling.
|
||||
await expect(deepestRow, "active folder keeps the current-item class").toHaveClass(/current-item/);
|
||||
|
||||
// Vertical scrolling still works on the tall tree.
|
||||
const verticalOverflowPx = await sidebarContent.evaluate((el) => el.scrollHeight - el.clientHeight);
|
||||
expect(verticalOverflowPx, "tree must be tall enough to scroll vertically").toBeGreaterThanOrEqual(
|
||||
MIN_VERTICAL_SCROLL_PX,
|
||||
);
|
||||
const scrollTopBefore = await sidebarContent.evaluate((el) => el.scrollTop);
|
||||
await page.mouse.wheel(0, 200);
|
||||
await expect
|
||||
.poll(() => sidebarContent.evaluate((el) => el.scrollTop), { message: "sidebar should scroll vertically" })
|
||||
.toBeGreaterThanOrEqual(scrollTopBefore + MIN_VERTICAL_SCROLL_PX);
|
||||
|
||||
// Expand and collapse still work: collapsing a folder removes its child row, expanding restores it.
|
||||
const levelOneExpandIcon = levelOneRow.locator(".expand-icon");
|
||||
await expect(levelTwoRow, "child row is visible before collapsing").toHaveCount(1);
|
||||
await levelOneExpandIcon.click();
|
||||
await expect(levelTwoRow, "collapsing a folder hides its children").toHaveCount(0);
|
||||
await levelOneExpandIcon.click();
|
||||
await expect(levelTwoRow, "expanding a folder shows its children again").toHaveCount(1);
|
||||
|
||||
checkForErrors();
|
||||
});
|
||||
Reference in new issue
Block a user