Change your password
You must choose a new password before continuing.
{{if .Error}}{{.Error}}
{{end}}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 (
You must choose a new password before continuing.
{{if .Error}}{{.Error}}
{{end}}