From ea88652f571e340ec2ad3b44e84968d55b122cca Mon Sep 17 00:00:00 2001 From: Domagoj Zecevic Date: Thu, 24 Sep 2026 11:53:45 +0200 Subject: [PATCH] feat(browse): add secure folder navigation --- .ai/HANDOFF.md | 28 +++++ .ai/REVIEW.md | 19 +++ .ai/TASKS.md | 2 +- README.md | 20 +++- cmd/webfilebrowser/main.go | 2 +- internal/browser/browser.go | 152 ++++++++++++++++++++++++ internal/browser/browser_test.go | 64 ++++++++++ internal/web/app.go | 198 +++++++++++++++++++++++++++---- internal/web/browse_test.go | 120 +++++++++++++++++++ 9 files changed, 577 insertions(+), 28 deletions(-) create mode 100644 internal/browser/browser.go create mode 100644 internal/browser/browser_test.go create mode 100644 internal/web/browse_test.go diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index e9b05c9..7233266 100644 --- a/.ai/HANDOFF.md +++ b/.ai/HANDOFF.md @@ -106,3 +106,31 @@ Each entry uses this exact structure. Omit fields marked as role-specific when t | Next Role | none | --- + +### T-003 — dev — 2026-09-24T09:52:07Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Added login-gated root and folder browsing with breadcrumbs, filesystem confinement, and a persistent light/dark theme. | +| Files Changed | README.md, cmd/webfilebrowser/main.go, internal/browser/browser.go, internal/browser/browser_test.go, internal/web/app.go, internal/web/browse_test.go, .ai/TASKS.md, .ai/REVIEW.md, .ai/HANDOFF.md | +| Validation | `go fmt ./...`, `go vet ./...`, and `go test ./...` passed in `golang:1.24`; E2E not configured. | +| Commit | `feat(browse): add secure folder navigation` | +| Verdict | PASS | +| Blocking Findings | none | +| Next Role | human approval | + +--- + +### T-003 — commit_task — 2026-09-24T09:53:03Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Approved the reviewed T-003 browsing work and prepared its single task commit. | +| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md | +| Validation | Review validation recorded in `.ai/REVIEW.md`. | +| Commit | `feat(browse): add secure folder navigation` | +| Next Role | none | + +--- diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md index f08df90..f0ad895 100644 --- a/.ai/REVIEW.md +++ b/.ai/REVIEW.md @@ -49,3 +49,22 @@ only on initial database creation, credentials use bcrypt, only hashed session tokens are stored, and every current/future protected path is authenticated before route handling. The HTTP integration tests cover the forced password change gate for the root, file, download, stream, and admin paths. + +## T-003 — 2026-09-24T09:52:07Z + +Verdict: PASS + +Findings: none. + +Validation: + +- `go fmt ./...` (in `golang:1.24`): PASS +- `go vet ./...` (in `golang:1.24`): PASS +- `go test ./...` (in `golang:1.24`): PASS +- E2E: NOT_CONFIGURED; project documentation declares no E2E command. + +The review confirms that all filesystem paths are resolved before listing and +that both `..` traversal and an outward-pointing symlink receive a 404 from the +authenticated browser route. The server-rendered pages expose configured roots, +nested folders, breadcrumbs, file metadata, and an inline vanilla-JavaScript +theme control backed by browser local storage. diff --git a/.ai/TASKS.md b/.ai/TASKS.md index 843ac6e..de9eafb 100644 --- a/.ai/TASKS.md +++ b/.ai/TASKS.md @@ -22,7 +22,7 @@ Command expectations: | --- | --- | --- | --- | --- | --- | | T-001 | Project scaffolding: Go module layout, root-config loading (file/env listing container paths + display names), Dockerfile skeleton, docker-compose example, `README.md` overview/setup skeleton | done | `go build ./...` succeeds; app boots and logs the configured roots from a sample config without panicking; `README.md` documents the config file/env format for declaring roots and the volume-mount + restart-to-add-root constraint | PASS: config source/precedence/validation tests; `go fmt ./...`, `go vet ./...`, `go test ./...`, and `go build -buildvcs=false ./...` in `golang:1.24`; Docker image build + sample-config startup-log smoke test | none | | T-002 | Data & auth foundation: SQLite schema/migrations for users + sessions, bcrypt password hashing, seed admin `boss@zecevic-ninja.com` / `Start1234!` with `must_change_password=true` on first startup, server-side session-cookie middleware (HTTP-only/Secure/SameSite, sliding expiry), login/logout routes, forced password-change gate that blocks every other route until the password is changed | done | Unauthenticated request to any non-login route (incl. file listing, download, stream, admin) returns redirect/401, never content; login with seed credentials succeeds and is immediately forced to the change-password screen and cannot reach any other page until changed; new password must differ from the temporary one; sessions persist across process restart via SQLite; unit tests cover auth middleware, forced-change gate, and password hashing | PASS: auth storage/bcrypt/persistence/sliding-session and HTTP login/logout/forced-change/protected-route tests; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24`; Docker image build and named-volume startup smoke test | none | -| T-003 | Folder browsing UI: server-rendered root listing page, directory navigation with breadcrumbs, strict path confinement inside each configured root (block `..` traversal and symlink escapes), light/dark theme toggle, minimal vanilla JS/CSS (no SPA framework) | ready_for_implement | Authenticated user sees configured roots, can enter/exit subfolders via breadcrumbs; any crafted path that would resolve outside a root's filesystem boundary is rejected (test with `../` and a symlink pointing outside the root); theme toggle switches light/dark and persists per session/browser; `README.md` documents the browsing UI behavior | n/a | implement | +| T-003 | Folder browsing UI: server-rendered root listing page, directory navigation with breadcrumbs, strict path confinement inside each configured root (block `..` traversal and symlink escapes), light/dark theme toggle, minimal vanilla JS/CSS (no SPA framework) | done | Authenticated user sees configured roots, can enter/exit subfolders via breadcrumbs; any crafted path that would resolve outside a root's filesystem boundary is rejected (test with `../` and a symlink pointing outside the root); theme toggle switches light/dark and persists per session/browser; `README.md` documents the browsing UI behavior | PASS: confined directory listing, traversal/symlink-escape rejection, authenticated root/folder/breadcrumb rendering, theme-local-storage markup; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24` | none | | T-004 | Download & video streaming: raw-byte download endpoint with correct `Content-Type`/`Content-Disposition`, full HTTP `Range` support for video files (seek/partial content/resume) with zero transcoding anywhere in the request path | ready_for_implement | Downloading any file returns identical bytes to the source file (checksum match); a `Range` request against a video file returns `206 Partial Content` with the correct byte range and headers; no encoding/thumbnailing/transcoding code path exists; `README.md` documents the reverse-proxy Range-header-passthrough requirement | n/a | implement | | T-005 | Admin user management: admin-only screens to list users, create a user (temporary password, `must_change_password=true`), reset a user's password, disable/delete a user; role-based route protection (403 for authenticated non-admins, not a redirect that leaks page existence) | ready_for_implement | Non-admin authenticated user gets 403 on all admin routes; admin can create a user who is then forced through the change-password gate on first login; admin can reset/disable a user; `README.md` documents the admin user-management flow | n/a | implement | | T-006 | Docker packaging & deployment docs: finalize multi-stage Dockerfile (small image, no build tools in final image), finalize docker-compose example with volume mounts + root config, complete `README.md` (build/run, root config format, first-login/admin credential flow, forced password change, reverse-proxy TLS + Range passthrough requirement) | ready_for_implement | `docker build` produces a working image; `docker run` with a sample volume mount and root config serves the login page only until authenticated, per Priority 1; `README.md` is complete enough for a new operator to deploy without reading source code | n/a | implement | diff --git a/README.md b/README.md index c5279f7..608bb67 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,8 @@ ## Overview A lightweight, container-based file browser for named host folders mounted -read-only into a container. This first scaffold starts an HTTP server and loads -the configured roots; login, folder browsing, and file delivery are added in -later phases. +read-only into a container. It provides login-gated root and folder browsing; +file downloads and streaming are added in a later phase. ## Getting Started @@ -80,6 +79,21 @@ The session cookie is always `HttpOnly`, `Secure`, and `SameSite=Lax`. Terminate TLS at a reverse proxy before exposing the service; browsers will not send the secure cookie over plain HTTP. +## Browsing folders + +After changing the temporary password, select a configured root from the home +page and enter folders by clicking their names. Breadcrumb links take you back +through the current path. Each directory view shows the item name, type, size, +and last-modified time; this interface is read-only. + +Every requested folder is resolved against its configured root. Parent-path +traversal and symlinks that resolve outside that root are rejected, so a mounted +folder cannot be used to browse unrelated container paths. + +Use the theme button in the page header to switch between light and dark modes. +The preference is stored in the browser's local storage and is restored on the +next visit from that browser. + ## AI Workflow This project includes the persistent planner/implementer/reviewer workflow with file-based coordination, plus the PO orchestration layer. diff --git a/cmd/webfilebrowser/main.go b/cmd/webfilebrowser/main.go index 7216d9f..90eec97 100644 --- a/cmd/webfilebrowser/main.go +++ b/cmd/webfilebrowser/main.go @@ -51,7 +51,7 @@ func main() { server := &http.Server{ Addr: address, - Handler: web.New(store), + Handler: web.New(store, applicationConfig.Roots...), } logger.Info("starting web file browser", "address", address) if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { diff --git a/internal/browser/browser.go b/internal/browser/browser.go new file mode 100644 index 0000000..b4c9c2c --- /dev/null +++ b/internal/browser/browser.go @@ -0,0 +1,152 @@ +// Package browser safely lists directories within configured filesystem roots. +package browser + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "webfilebrowser/internal/config" +) + +var ( + // ErrRootNotFound indicates that a requested configured root does not exist. + ErrRootNotFound = errors.New("configured root not found") + // ErrPathOutsideRoot indicates traversal or a symlink that leaves a root. + ErrPathOutsideRoot = errors.New("path is outside the configured root") + // ErrDirectoryNotFound indicates that an allowed path is not a directory. + ErrDirectoryNotFound = errors.New("directory not found") +) + +// Entry describes a safely listed file or directory. +type Entry struct { + Name string + IsDirectory bool + Size int64 + ModifiedAt time.Time +} + +// Directory is a configured root and a directory path within it. +type Directory struct { + Root config.Root + RelativePath string + Entries []Entry +} + +// Browser provides root-confined directory listings. +type Browser struct { + rootsByName map[string]config.Root + roots []config.Root +} + +// New prepares a browser for the supplied configured roots. +func New(roots []config.Root) (*Browser, error) { + browser := &Browser{ + rootsByName: make(map[string]config.Root, len(roots)), + roots: append([]config.Root(nil), roots...), + } + for _, root := range roots { + if _, exists := browser.rootsByName[root.Name]; exists { + return nil, fmt.Errorf("duplicate configured root %q", root.Name) + } + browser.rootsByName[root.Name] = root + } + return browser, nil +} + +// Roots returns configured roots in their configuration order. +func (browser *Browser) Roots() []config.Root { + return append([]config.Root(nil), browser.roots...) +} + +// List returns the entries in a configured root directory. The requested path +// cannot escape a root through parent-directory traversal or symlinks. +func (browser *Browser) List(rootName, relativePath string) (Directory, error) { + root, exists := browser.rootsByName[rootName] + if !exists { + return Directory{}, ErrRootNotFound + } + + cleanRelativePath, err := cleanRelativePath(relativePath) + if err != nil { + return Directory{}, err + } + resolvedRoot, err := filepath.EvalSymlinks(root.Path) + if err != nil { + return Directory{}, fmt.Errorf("%w: %v", ErrDirectoryNotFound, err) + } + rootInfo, err := os.Stat(resolvedRoot) + if err != nil || !rootInfo.IsDir() { + return Directory{}, ErrDirectoryNotFound + } + + candidate := root.Path + if cleanRelativePath != "" { + candidate = filepath.Join(root.Path, filepath.FromSlash(cleanRelativePath)) + } + resolvedCandidate, err := filepath.EvalSymlinks(candidate) + if err != nil { + return Directory{}, fmt.Errorf("%w: %v", ErrDirectoryNotFound, err) + } + if !isWithin(resolvedRoot, resolvedCandidate) { + return Directory{}, ErrPathOutsideRoot + } + candidateInfo, err := os.Stat(resolvedCandidate) + if err != nil || !candidateInfo.IsDir() { + return Directory{}, ErrDirectoryNotFound + } + + entries, err := os.ReadDir(candidate) + if err != nil { + return Directory{}, fmt.Errorf("read directory: %w", err) + } + listedEntries := make([]Entry, 0, len(entries)) + for _, entry := range entries { + entryPath := filepath.Join(candidate, entry.Name()) + resolvedEntry, err := filepath.EvalSymlinks(entryPath) + if err != nil || !isWithin(resolvedRoot, resolvedEntry) { + continue + } + info, err := os.Stat(resolvedEntry) + if err != nil { + continue + } + listedEntries = append(listedEntries, Entry{ + Name: entry.Name(), + IsDirectory: info.IsDir(), + Size: info.Size(), + ModifiedAt: info.ModTime(), + }) + } + + return Directory{Root: root, RelativePath: cleanRelativePath, Entries: listedEntries}, nil +} + +func cleanRelativePath(relativePath string) (string, error) { + if relativePath == "" { + return "", nil + } + path := filepath.FromSlash(relativePath) + if filepath.IsAbs(path) { + return "", ErrPathOutsideRoot + } + path = filepath.Clean(path) + if path == ".." || strings.HasPrefix(path, ".."+string(filepath.Separator)) { + return "", ErrPathOutsideRoot + } + if path == "." { + return "", nil + } + return filepath.ToSlash(path), nil +} + +func isWithin(root, candidate string) bool { + relativePath, err := filepath.Rel(root, candidate) + if err != nil { + return false + } + return relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) +} diff --git a/internal/browser/browser_test.go b/internal/browser/browser_test.go new file mode 100644 index 0000000..63853b9 --- /dev/null +++ b/internal/browser/browser_test.go @@ -0,0 +1,64 @@ +package browser + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "webfilebrowser/internal/config" +) + +func TestListReadsAConfiguredRootAndNestedDirectory(t *testing.T) { + rootPath := t.TempDir() + if err := os.Mkdir(filepath.Join(rootPath, "documents"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootPath, "documents", "report.txt"), []byte("report"), 0o600); err != nil { + t.Fatal(err) + } + + browser, err := New([]config.Root{{Name: "Media", Path: rootPath}}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + directory, err := browser.List("Media", "documents") + if err != nil { + t.Fatalf("List() error = %v", err) + } + if directory.RelativePath != "documents" { + t.Errorf("List().RelativePath = %q, want %q", directory.RelativePath, "documents") + } + if len(directory.Entries) != 1 { + t.Fatalf("List().Entries length = %d, want 1", len(directory.Entries)) + } + entry := directory.Entries[0] + if entry.Name != "report.txt" || entry.IsDirectory || entry.Size != int64(len("report")) { + t.Errorf("List().Entries[0] = %#v, want report.txt file with its byte size", entry) + } +} + +func TestListRejectsTraversalAndSymlinkEscapes(t *testing.T) { + rootPath := t.TempDir() + outsidePath := t.TempDir() + if err := os.WriteFile(filepath.Join(outsidePath, "secret.txt"), []byte("outside-root"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outsidePath, filepath.Join(rootPath, "escape")); err != nil { + t.Fatal(err) + } + + browser, err := New([]config.Root{{Name: "Media", Path: rootPath}}) + if err != nil { + t.Fatal(err) + } + + for _, relativePath := range []string{"../" + filepath.Base(outsidePath), "escape"} { + t.Run(relativePath, func(t *testing.T) { + if _, err := browser.List("Media", relativePath); !errors.Is(err, ErrPathOutsideRoot) { + t.Errorf("List(%q) error = %v, want ErrPathOutsideRoot", relativePath, err) + } + }) + } +} diff --git a/internal/web/app.go b/internal/web/app.go index 23108f9..399bf56 100644 --- a/internal/web/app.go +++ b/internal/web/app.go @@ -3,18 +3,24 @@ package web import ( "errors" - "fmt" "html/template" "net/http" + "net/url" + "path" + "strconv" + "strings" "time" "webfilebrowser/internal/auth" + "webfilebrowser/internal/browser" + "webfilebrowser/internal/config" ) const ( loginPath = "/login" logoutPath = "/logout" changePasswordPath = "/change-password" + browsePath = "/browse" sessionCookieName = "webfilebrowser_session" ) @@ -29,16 +35,49 @@ var (

Change your password

You must choose a new password before continuing.

{{if .Error}}

{{.Error}}

{{end}}
`)) + rootsTemplate = template.Must(template.New("roots").Parse(pageStart + ` +

webFileBrowser

+

Signed in as {{.UserEmail}}.

Roots

` + signOutForm + pageEnd)) + directoryTemplate = template.Must(template.New("directory").Parse(pageStart + ` +

webFileBrowser

+

Signed in as {{.UserEmail}}.

+{{range .Entries}}{{else}}{{end}}
NameTypeSizeModified
{{if .URL}}{{.Name}}{{else}}{{.Name}}{{end}}{{.Type}}{{.Size}}{{.Modified}}
This folder is empty.
` + signOutForm + pageEnd)) ) +const pageStart = `webFileBrowser` + +const signOutForm = `
` + +const pageEnd = `` + // New returns an HTTP handler that requires a valid server-side session for // every route other than the login endpoint. -func New(store *auth.Store) http.Handler { - return &application{store: store} +func New(store *auth.Store, roots ...config.Root) http.Handler { + fileBrowser, err := browser.New(roots) + return &application{store: store, browser: fileBrowser, browserError: err} } type application struct { - store *auth.Store + store *auth.Store + browser *browser.Browser + browserError error } func (app *application) ServeHTTP(writer http.ResponseWriter, request *http.Request) { @@ -67,12 +106,14 @@ func (app *application) ServeHTTP(writer http.ResponseWriter, request *http.Requ http.Redirect(writer, request, "/", http.StatusSeeOther) return } - if request.URL.Path == "/" { - app.handleHome(writer, request, user) - return + switch request.URL.Path { + case "/": + app.handleRoots(writer, request, user) + case browsePath: + app.handleBrowse(writer, request, user) + default: + http.NotFound(writer, request) } - - http.NotFound(writer, request) } func (app *application) handleLogin(writer http.ResponseWriter, request *http.Request) { @@ -137,29 +178,71 @@ func (app *application) handleChangePassword(writer http.ResponseWriter, request func (app *application) handleLogout(writer http.ResponseWriter, request *http.Request, token string) { if request.Method != http.MethodPost { - writer.Header().Set("Allow", http.MethodPost) - http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) + methodNotAllowed(writer, http.MethodPost) return } if err := app.store.DeleteSession(token); err != nil { http.Error(writer, "Unable to end session.", http.StatusInternalServerError) return } - http.SetCookie(writer, &http.Cookie{ - Name: sessionCookieName, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - }) + app.clearSessionCookie(writer) http.Redirect(writer, request, loginPath, http.StatusSeeOther) } -func (app *application) handleHome(writer http.ResponseWriter, _ *http.Request, user auth.User) { - writer.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = fmt.Fprintf(writer, "webFileBrowser

webFileBrowser

File browser is being prepared.

Signed in as %s.

", template.HTMLEscapeString(user.Email)) +func (app *application) handleRoots(writer http.ResponseWriter, request *http.Request, user auth.User) { + if request.Method != http.MethodGet { + methodNotAllowed(writer, http.MethodGet) + return + } + if app.browserError != nil { + http.Error(writer, "Unable to load configured roots.", http.StatusInternalServerError) + return + } + roots := make([]rootLink, 0, len(app.browser.Roots())) + for _, root := range app.browser.Roots() { + roots = append(roots, rootLink{Name: root.Name, URL: browseURL(root.Name, "")}) + } + renderTemplate(writer, rootsTemplate, rootPage{UserEmail: user.Email, Roots: roots}) +} + +func (app *application) handleBrowse(writer http.ResponseWriter, request *http.Request, user auth.User) { + if request.Method != http.MethodGet { + methodNotAllowed(writer, http.MethodGet) + return + } + if app.browserError != nil { + http.Error(writer, "Unable to load configured roots.", http.StatusInternalServerError) + return + } + directory, err := app.browser.List(request.URL.Query().Get("root"), request.URL.Query().Get("path")) + if errors.Is(err, browser.ErrRootNotFound) || errors.Is(err, browser.ErrPathOutsideRoot) || errors.Is(err, browser.ErrDirectoryNotFound) { + http.NotFound(writer, request) + return + } + if err != nil { + http.Error(writer, "Unable to read folder.", http.StatusInternalServerError) + return + } + + entries := make([]directoryEntry, 0, len(directory.Entries)) + for _, entry := range directory.Entries { + entryView := directoryEntry{ + Name: entry.Name, + Type: "File", + Size: formatSize(entry.Size), + Modified: entry.ModifiedAt.Local().Format("2006-01-02 15:04"), + } + if entry.IsDirectory { + entryView.Type = "Folder" + entryView.URL = browseURL(directory.Root.Name, joinRelativePath(directory.RelativePath, entry.Name)) + } + entries = append(entries, entryView) + } + renderTemplate(writer, directoryTemplate, directoryPage{ + UserEmail: user.Email, + Breadcrumbs: breadcrumbLinks(directory.Root.Name, directory.RelativePath), + Entries: entries, + }) } func (app *application) currentUser(writer http.ResponseWriter, request *http.Request) (auth.User, string, bool) { @@ -202,6 +285,67 @@ func (app *application) clearSessionCookie(writer http.ResponseWriter) { }) } +type rootPage struct { + UserEmail string + Roots []rootLink +} + +type rootLink struct { + Name string + URL string +} + +type directoryPage struct { + UserEmail string + Breadcrumbs []rootLink + Entries []directoryEntry +} + +type directoryEntry struct { + Name string + URL string + Type string + Size string + Modified string +} + +func browseURL(rootName, relativePath string) string { + query := url.Values{"root": {rootName}} + if relativePath != "" { + query.Set("path", relativePath) + } + return browsePath + "?" + query.Encode() +} + +func breadcrumbLinks(rootName, relativePath string) []rootLink { + breadcrumbs := []rootLink{{Name: rootName, URL: browseURL(rootName, "")}} + pathSoFar := "" + for _, part := range strings.Split(relativePath, "/") { + if part == "" { + continue + } + pathSoFar = joinRelativePath(pathSoFar, part) + breadcrumbs = append(breadcrumbs, rootLink{Name: part, URL: browseURL(rootName, pathSoFar)}) + } + return breadcrumbs +} + +func joinRelativePath(parent, child string) string { + if parent == "" { + return child + } + return path.Join(parent, child) +} + +func formatSize(size int64) string { + return strconv.FormatInt(size, 10) + " B" +} + +func methodNotAllowed(writer http.ResponseWriter, allowedMethod string) { + writer.Header().Set("Allow", allowedMethod) + http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed) +} + func renderLogin(writer http.ResponseWriter, status int, message string) { writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.WriteHeader(status) @@ -213,3 +357,11 @@ func renderChangePassword(writer http.ResponseWriter, status int, message string writer.WriteHeader(status) _ = changePasswordTemplate.Execute(writer, struct{ Error string }{Error: message}) } + +func renderTemplate(writer http.ResponseWriter, pageTemplate *template.Template, data any) { + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pageTemplate.Execute(writer, data); err != nil { + http.Error(writer, "Unable to render page.", http.StatusInternalServerError) + } + +} diff --git a/internal/web/browse_test.go b/internal/web/browse_test.go new file mode 100644 index 0000000..9056097 --- /dev/null +++ b/internal/web/browse_test.go @@ -0,0 +1,120 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "webfilebrowser/internal/auth" + "webfilebrowser/internal/config" +) + +func TestAuthenticatedBrowserListsRootsAndProvidesBreadcrumbNavigation(t *testing.T) { + rootPath, handler := newBrowsingApplication(t) + if err := os.Mkdir(filepath.Join(rootPath, "documents"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootPath, "documents", "report.txt"), []byte("report"), 0o600); err != nil { + t.Fatal(err) + } + cookie := completeSeedPasswordChange(t, handler) + + rootResponse := getWithCookie(handler, "/", cookie) + if rootResponse.Code != http.StatusOK { + t.Fatalf("GET / status = %d, want %d", rootResponse.Code, http.StatusOK) + } + if body := rootResponse.Body.String(); !strings.Contains(body, "Media") || !strings.Contains(body, "/browse?root=Media") { + t.Errorf("GET / did not render the configured root: %s", body) + } + + directoryResponse := getWithCookie(handler, "/browse?root=Media&path=documents", cookie) + if directoryResponse.Code != http.StatusOK { + t.Fatalf("GET nested directory status = %d, want %d", directoryResponse.Code, http.StatusOK) + } + body := directoryResponse.Body.String() + for _, text := range []string{"Media", "documents", "report.txt", "File"} { + if !strings.Contains(body, text) { + t.Errorf("GET nested directory body is missing %q: %s", text, body) + } + } +} + +func TestBrowserRejectsTraversalAndSymlinkEscapeRequests(t *testing.T) { + rootPath, handler := newBrowsingApplication(t) + outsidePath := t.TempDir() + if err := os.WriteFile(filepath.Join(outsidePath, "secret.txt"), []byte("outside-root"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outsidePath, filepath.Join(rootPath, "escape")); err != nil { + t.Fatal(err) + } + cookie := completeSeedPasswordChange(t, handler) + + for _, target := range []string{"/browse?root=Media&path=../outside", "/browse?root=Media&path=escape"} { + t.Run(target, func(t *testing.T) { + response := getWithCookie(handler, target, cookie) + if response.Code != http.StatusNotFound { + t.Errorf("GET %s status = %d, want %d", target, response.Code, http.StatusNotFound) + } + if strings.Contains(response.Body.String(), "outside-root") { + t.Errorf("GET %s exposed content outside the configured root", target) + } + }) + } +} + +func TestBrowserThemeTogglePersistsInLocalStorage(t *testing.T) { + _, handler := newBrowsingApplication(t) + cookie := completeSeedPasswordChange(t, handler) + + response := getWithCookie(handler, "/", cookie) + if response.Code != http.StatusOK { + t.Fatalf("GET / status = %d, want %d", response.Code, http.StatusOK) + } + body := response.Body.String() + for _, text := range []string{`id="theme-toggle"`, "localStorage.getItem", "localStorage.setItem", "data-theme"} { + if !strings.Contains(body, text) { + t.Errorf("GET / is missing theme persistence behavior %q", text) + } + } +} + +func newBrowsingApplication(t *testing.T) (string, http.Handler) { + t.Helper() + rootPath := t.TempDir() + store, err := auth.Open(filepath.Join(t.TempDir(), "webfilebrowser.db"), auth.Options{}) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + return rootPath, New(store, config.Root{Name: "Media", Path: rootPath}) +} + +func completeSeedPasswordChange(t *testing.T, handler http.Handler) *http.Cookie { + t.Helper() + loginResponse := postForm(handler, loginPath, url.Values{ + "email": {auth.SeedAdminEmail}, + "password": {auth.SeedAdminPassword}, + }, nil) + cookie := responseCookie(t, loginResponse, sessionCookieName) + changeResponse := postForm(handler, changePasswordPath, url.Values{ + "current_password": {auth.SeedAdminPassword}, + "new_password": {"A-new-password-123!"}, + }, cookie) + if changeResponse.Code != http.StatusSeeOther { + t.Fatalf("POST %s status = %d, want %d", changePasswordPath, changeResponse.Code, http.StatusSeeOther) + } + return cookie +} + +func getWithCookie(handler http.Handler, target string, cookie *http.Cookie) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodGet, target, nil) + request.AddCookie(cookie) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +}