package fbhttp

import (
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/asdine/storm/v3"
	"github.com/golang-jwt/jwt/v5"
	"github.com/spf13/afero"

	"github.com/filebrowser/filebrowser/v2/diskcache"
	"github.com/filebrowser/filebrowser/v2/settings"
	"github.com/filebrowser/filebrowser/v2/storage"
	"github.com/filebrowser/filebrowser/v2/storage/bolt"
	"github.com/filebrowser/filebrowser/v2/users"
)

func TestResourceCopyDoesNotDereferenceEscapingSymlink(t *testing.T) {
	root := t.TempDir()
	userScope := filepath.Join(root, "user")
	if err := os.MkdirAll(filepath.Join(userScope, "srcdir"), 0o755); err != nil {
		t.Fatal(err)
	}

	// An ordinary in-scope file, to prove a normal copy still works.
	if err := os.WriteFile(filepath.Join(userScope, "srcdir", "normal.txt"), []byte("in-scope"), 0o644); err != nil {
		t.Fatal(err)
	}

	// The secret living outside the user's scope.
	secret := filepath.Join(root, "secret.txt")
	if err := os.WriteFile(secret, []byte("OUT-OF-SCOPE-SECRET"), 0o644); err != nil {
		t.Fatal(err)
	}

	// An escaping symlink planted inside the user's scope (out-of-band, as the
	// advisory's preconditions describe).
	if err := os.Symlink(secret, filepath.Join(userScope, "srcdir", "link.txt")); err != nil {
		t.Skipf("cannot create symlink: %v", err)
	}

	key := []byte("test-signing-key")

	db, err := storm.Open(filepath.Join(t.TempDir(), "db"))
	if err != nil {
		t.Fatalf("failed to open db: %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })

	st, err := bolt.NewStorage(db)
	if err != nil {
		t.Fatalf("failed to get storage: %v", err)
	}
	perm := users.Permissions{Create: true, Modify: true, Download: true, Rename: true}
	if err := st.Users.Save(&users.User{Username: "u", Password: "pw", Perm: perm}); err != nil {
		t.Fatalf("failed to save user: %v", err)
	}
	if err := st.Settings.Save(&settings.Settings{Key: key}); err != nil {
		t.Fatalf("failed to save settings: %v", err)
	}
	// The user's scope is the real on-disk userScope. customFSUser.Get wraps it
	// in a ScopedFs, mirroring production (users.User init).
	st.Users = &customFSUser{
		Store: st.Users,
		fs:    afero.NewBasePathFs(afero.NewOsFs(), userScope),
	}

	signed := signToken(t, perm, key)

	t.Run("direct raw read is forbidden", func(t *testing.T) {
		req, _ := http.NewRequest(http.MethodGet, "/srcdir/link.txt", http.NoBody)
		req.Header.Set("X-Auth", signed)
		rec := httptest.NewRecorder()
		handle(rawHandler, "", st, &settings.Server{}).ServeHTTP(rec, req)
		if rec.Code == http.StatusOK {
			t.Fatalf("VULNERABLE: direct raw read of escaping symlink returned 200, body=%q", rec.Body.String())
		}
	})

	t.Run("recursive copy does not exfiltrate", func(t *testing.T) {
		req, _ := http.NewRequest(http.MethodPatch, "/srcdir/", http.NoBody)
		q := req.URL.Query()
		q.Set("action", "copy")
		q.Set("destination", "/dstdir")
		req.URL.RawQuery = q.Encode()
		req.Header.Set("X-Auth", signed)

		rec := httptest.NewRecorder()
		handle(resourcePatchHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req)
		t.Logf("copy status=%d body=%q", rec.Code, rec.Body.String())

		// The escaping symlink's target content must never appear in scope.
		leaked := filepath.Join(userScope, "dstdir", "link.txt")
		if data, readErr := os.ReadFile(leaked); readErr == nil {
			t.Fatalf("VULNERABLE: out-of-scope content landed in scope at %s: %q", leaked, string(data))
		}
	})
}

func signToken(t *testing.T, perm users.Permissions, key []byte) string {
	t.Helper()
	claims := &authToken{
		User: userInfo{ID: 1, Username: "u", Perm: perm},
		RegisteredClaims: jwt.RegisteredClaims{
			IssuedAt:  jwt.NewNumericDate(time.Now().Add(-time.Minute)),
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
		},
	}
	signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(key)
	if err != nil {
		t.Fatalf("failed to sign token: %v", err)
	}
	return signed
}

// scopedUserStorage returns a storage whose single user (ID 1) is scoped to
// userScope through a symlink-confining ScopedFs (via customFSUser), mirroring
// production. Used by the symlink scope-escape regression tests below.
func scopedUserStorage(t *testing.T, userScope string, perm users.Permissions, key []byte) *storage.Storage {
	t.Helper()
	db, err := storm.Open(filepath.Join(t.TempDir(), "db"))
	if err != nil {
		t.Fatalf("failed to open db: %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })

	st, err := bolt.NewStorage(db)
	if err != nil {
		t.Fatalf("failed to get storage: %v", err)
	}
	if err := st.Users.Save(&users.User{Username: "u", Password: "pw", Perm: perm}); err != nil {
		t.Fatalf("failed to save user: %v", err)
	}
	if err := st.Settings.Save(&settings.Settings{Key: key}); err != nil {
		t.Fatalf("failed to save settings: %v", err)
	}
	st.Users = &customFSUser{
		Store: st.Users,
		fs:    afero.NewBasePathFs(afero.NewOsFs(), userScope),
	}
	return st
}

// Regression for the dangling-symlink write escape (GHSA-8wc8-hf36-mjh9 /
// GHSA-fh54-6rfh-r8f3): POSTing to an in-scope dangling symlink whose target is
// outside the scope must not dereference the link to create the out-of-scope
// file.
func TestResourcePostRejectsDanglingSymlinkWriteEscape(t *testing.T) {
	root := t.TempDir()
	userScope := filepath.Join(root, "user")
	outside := filepath.Join(root, "outside")
	for _, d := range []string{userScope, outside} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	// A dangling symlink inside the scope pointing at a not-yet-existing file
	// outside it (planted out-of-band, per the advisory preconditions).
	outsideTarget := filepath.Join(outside, "created.txt")
	if err := os.Symlink(outsideTarget, filepath.Join(userScope, "evil")); err != nil {
		t.Skipf("cannot create symlink: %v", err)
	}

	key := []byte("test-signing-key")
	perm := users.Permissions{Create: true, Modify: true}
	st := scopedUserStorage(t, userScope, perm, key)
	signed := signToken(t, perm, key)

	req, _ := http.NewRequest(http.MethodPost, "/evil?override=true", strings.NewReader("http-outside"))
	req.Header.Set("X-Auth", signed)
	rec := httptest.NewRecorder()
	handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req)

	if _, statErr := os.Stat(outsideTarget); statErr == nil {
		data, _ := os.ReadFile(outsideTarget)
		t.Fatalf("VULNERABLE: out-of-scope file created via dangling symlink (status=%d, content=%q)", rec.Code, string(data))
	}
	if rec.Code != http.StatusForbidden {
		t.Errorf("expected 403, got %d body=%q", rec.Code, rec.Body.String())
	}
}

// Regression for the symlink-following delete escape (GHSA-hq4g-mpch-f9vp /
// GHSA-fmm7-x4gx-8jhr): a Create-only user POSTing to a child of an escaping
// symlinked directory must not delete the out-of-scope target through the
// failed-upload cleanup RemoveAll.
func TestResourcePostCleanupDoesNotDeleteThroughSymlink(t *testing.T) {
	root := t.TempDir()
	userScope := filepath.Join(root, "user")
	outside := filepath.Join(root, "outside")
	for _, d := range []string{userScope, outside} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	victim := filepath.Join(outside, "victim.txt")
	if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
		t.Fatal(err)
	}
	// An escaping directory symlink inside the scope (planted out-of-band).
	if err := os.Symlink(outside, filepath.Join(userScope, "link")); err != nil {
		t.Skipf("cannot create symlink: %v", err)
	}

	key := []byte("test-signing-key")
	// Create-only: Perm.Delete is deliberately false — the bug must not need it.
	perm := users.Permissions{Create: true}
	st := scopedUserStorage(t, userScope, perm, key)
	signed := signToken(t, perm, key)

	req, _ := http.NewRequest(http.MethodPost, "/link/victim.txt", strings.NewReader("x"))
	req.Header.Set("X-Auth", signed)
	rec := httptest.NewRecorder()
	handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req)

	if _, statErr := os.Stat(victim); statErr != nil {
		t.Fatalf("VULNERABLE: out-of-scope victim.txt deleted by cleanup RemoveAll (status=%d): %v", rec.Code, statErr)
	}
}

func TestResourcePostRunsUploadHooksForDirectories(t *testing.T) {
	root := t.TempDir()
	userScope := filepath.Join(root, "user")
	if err := os.MkdirAll(userScope, 0o755); err != nil {
		t.Fatal(err)
	}

	key := []byte("test-signing-key")
	perm := users.Permissions{Create: true}
	st := scopedUserStorage(t, userScope, perm, key)
	if err := st.Settings.Save(&settings.Settings{
		Key: key,
		Commands: map[string][]string{
			"after_upload": {"filebrowser-hook-command-that-does-not-exist"},
		},
	}); err != nil {
		t.Fatal(err)
	}

	req, _ := http.NewRequest(http.MethodPost, "/created/", http.NoBody)
	req.Header.Set("X-Auth", signToken(t, perm, key))
	rec := httptest.NewRecorder()
	handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{EnableExec: true}).ServeHTTP(rec, req)

	// A missing after_upload command makes the request fail only if the hook ran.
	// It avoids a platform-specific helper executable while still exercising the
	// same path the web UI uses for directory uploads.
	if rec.Code != http.StatusInternalServerError {
		t.Fatalf("expected directory upload hook failure to return 500, got %d body=%q", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(userScope, "created")); err != nil {
		t.Fatalf("expected directory to be created before its after hook, got %v", err)
	}
}
