Browse Source

feat(browse): add secure folder navigation

main
Domagoj Zecevic 1 month ago
parent
commit
ea88652f57
  1. 28
      .ai/HANDOFF.md
  2. 19
      .ai/REVIEW.md
  3. 2
      .ai/TASKS.md
  4. 20
      README.md
  5. 2
      cmd/webfilebrowser/main.go
  6. 152
      internal/browser/browser.go
  7. 64
      internal/browser/browser_test.go
  8. 196
      internal/web/app.go
  9. 120
      internal/web/browse_test.go

28
.ai/HANDOFF.md

@ -106,3 +106,31 @@ Each entry uses this exact structure. Omit fields marked as role-specific when t
| Next Role | none | | 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 |
---

19
.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 tokens are stored, and every current/future protected path is authenticated
before route handling. The HTTP integration tests cover the forced password before route handling. The HTTP integration tests cover the forced password
change gate for the root, file, download, stream, and admin paths. 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.

2
.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-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-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-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-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 | | 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 |

20
README.md

@ -3,9 +3,8 @@
## Overview ## Overview
A lightweight, container-based file browser for named host folders mounted 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 read-only into a container. It provides login-gated root and folder browsing;
the configured roots; login, folder browsing, and file delivery are added in file downloads and streaming are added in a later phase.
later phases.
## Getting Started ## 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 Terminate TLS at a reverse proxy before exposing the service; browsers will not
send the secure cookie over plain HTTP. 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 ## AI Workflow
This project includes the persistent planner/implementer/reviewer workflow with file-based coordination, plus the PO orchestration layer. This project includes the persistent planner/implementer/reviewer workflow with file-based coordination, plus the PO orchestration layer.

2
cmd/webfilebrowser/main.go

@ -51,7 +51,7 @@ func main() {
server := &http.Server{ server := &http.Server{
Addr: address, Addr: address,
Handler: web.New(store), Handler: web.New(store, applicationConfig.Roots...),
} }
logger.Info("starting web file browser", "address", address) logger.Info("starting web file browser", "address", address)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {

152
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))
}

64
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)
}
})
}
}

196
internal/web/app.go

@ -3,18 +3,24 @@ package web
import ( import (
"errors" "errors"
"fmt"
"html/template" "html/template"
"net/http" "net/http"
"net/url"
"path"
"strconv"
"strings"
"time" "time"
"webfilebrowser/internal/auth" "webfilebrowser/internal/auth"
"webfilebrowser/internal/browser"
"webfilebrowser/internal/config"
) )
const ( const (
loginPath = "/login" loginPath = "/login"
logoutPath = "/logout" logoutPath = "/logout"
changePasswordPath = "/change-password" changePasswordPath = "/change-password"
browsePath = "/browse"
sessionCookieName = "webfilebrowser_session" sessionCookieName = "webfilebrowser_session"
) )
@ -29,16 +35,49 @@ var (
<body><main><h1>Change your password</h1><p>You must choose a new password before continuing.</p>{{if .Error}}<p role="alert">{{.Error}}</p>{{end}} <body><main><h1>Change your password</h1><p>You must choose a new password before continuing.</p>{{if .Error}}<p role="alert">{{.Error}}</p>{{end}}
<form method="post" action="/change-password"><label>Current password <input type="password" name="current_password" required></label> <form method="post" action="/change-password"><label>Current password <input type="password" name="current_password" required></label>
<label>New password <input type="password" name="new_password" required></label><button type="submit">Change password</button></form></main></body></html>`)) <label>New password <input type="password" name="new_password" required></label><button type="submit">Change password</button></form></main></body></html>`))
rootsTemplate = template.Must(template.New("roots").Parse(pageStart + `
<main><header><h1>webFileBrowser</h1><button id="theme-toggle" type="button">Toggle theme</button></header>
<p>Signed in as {{.UserEmail}}.</p><h2>Roots</h2><ul class="roots">{{range .Roots}}<li><a href="{{.URL}}">{{.Name}}</a></li>{{else}}<li>No roots are configured.</li>{{end}}</ul>` + signOutForm + pageEnd))
directoryTemplate = template.Must(template.New("directory").Parse(pageStart + `
<main><header><h1>webFileBrowser</h1><button id="theme-toggle" type="button">Toggle theme</button></header>
<p>Signed in as {{.UserEmail}}.</p><nav aria-label="Breadcrumb"><ol class="breadcrumbs">{{range .Breadcrumbs}}<li><a href="{{.URL}}">{{.Name}}</a></li>{{end}}</ol></nav>
<table><thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Modified</th></tr></thead><tbody>{{range .Entries}}<tr><td>{{if .URL}}<a href="{{.URL}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td>{{.Type}}</td><td>{{.Size}}</td><td>{{.Modified}}</td></tr>{{else}}<tr><td colspan="4">This folder is empty.</td></tr>{{end}}</tbody></table>` + signOutForm + pageEnd))
) )
const pageStart = `<!doctype html><html lang="en" data-theme="light"><head><meta charset="utf-8"><title>webFileBrowser</title><style>
:root{color-scheme:light;--background:#f6f7fb;--surface:#fff;--text:#202534;--muted:#5d6577;--border:#d7dce8;--link:#2251b5}:root[data-theme="dark"]{color-scheme:dark;--background:#151821;--surface:#202532;--text:#f1f4fb;--muted:#b7c0d2;--border:#3c465b;--link:#9ec0ff}*{box-sizing:border-box}body{margin:0;background:var(--background);color:var(--text);font:16px system-ui,sans-serif}main{max-width:960px;margin:2rem auto;padding:2rem;background:var(--surface);border:1px solid var(--border);border-radius:12px}header{display:flex;justify-content:space-between;align-items:center;gap:1rem}h1{margin:0}a{color:var(--link)}button{font:inherit;padding:.45rem .75rem}table{width:100%;border-collapse:collapse}th,td{padding:.7rem;text-align:left;border-bottom:1px solid var(--border)}.roots,.breadcrumbs{padding-left:1.2rem}.breadcrumbs{display:flex;gap:.5rem;flex-wrap:wrap}.breadcrumbs li+li:before{content:"/";margin-right:.5rem;color:var(--muted)}form{margin-top:1.5rem}</style></head><body>`
const signOutForm = `<form method="post" action="/logout"><button type="submit">Sign out</button></form>`
const pageEnd = `<script>
(() => {
const key = "webfilebrowser-theme";
const button = document.getElementById("theme-toggle");
const apply = (theme) => {
document.documentElement.dataset.theme = theme;
button.textContent = theme === "dark" ? "Use light theme" : "Use dark theme";
};
const saved = localStorage.getItem(key);
apply(saved === "dark" ? "dark" : "light");
button.addEventListener("click", () => {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
localStorage.setItem(key, next);
apply(next);
});
})();
</script></body></html>`
// New returns an HTTP handler that requires a valid server-side session for // New returns an HTTP handler that requires a valid server-side session for
// every route other than the login endpoint. // every route other than the login endpoint.
func New(store *auth.Store) http.Handler { func New(store *auth.Store, roots ...config.Root) http.Handler {
return &application{store: store} fileBrowser, err := browser.New(roots)
return &application{store: store, browser: fileBrowser, browserError: err}
} }
type application struct { type application struct {
store *auth.Store store *auth.Store
browser *browser.Browser
browserError error
} }
func (app *application) ServeHTTP(writer http.ResponseWriter, request *http.Request) { func (app *application) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
@ -67,13 +106,15 @@ func (app *application) ServeHTTP(writer http.ResponseWriter, request *http.Requ
http.Redirect(writer, request, "/", http.StatusSeeOther) http.Redirect(writer, request, "/", http.StatusSeeOther)
return return
} }
if request.URL.Path == "/" { switch request.URL.Path {
app.handleHome(writer, request, user) case "/":
return 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) { func (app *application) handleLogin(writer http.ResponseWriter, request *http.Request) {
switch request.Method { switch request.Method {
@ -137,29 +178,71 @@ func (app *application) handleChangePassword(writer http.ResponseWriter, request
func (app *application) handleLogout(writer http.ResponseWriter, request *http.Request, token string) { func (app *application) handleLogout(writer http.ResponseWriter, request *http.Request, token string) {
if request.Method != http.MethodPost { if request.Method != http.MethodPost {
writer.Header().Set("Allow", http.MethodPost) methodNotAllowed(writer, http.MethodPost)
http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed)
return return
} }
if err := app.store.DeleteSession(token); err != nil { if err := app.store.DeleteSession(token); err != nil {
http.Error(writer, "Unable to end session.", http.StatusInternalServerError) http.Error(writer, "Unable to end session.", http.StatusInternalServerError)
return return
} }
http.SetCookie(writer, &http.Cookie{ app.clearSessionCookie(writer)
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(writer, request, loginPath, http.StatusSeeOther) http.Redirect(writer, request, loginPath, http.StatusSeeOther)
} }
func (app *application) handleHome(writer http.ResponseWriter, _ *http.Request, user auth.User) { func (app *application) handleRoots(writer http.ResponseWriter, request *http.Request, user auth.User) {
writer.Header().Set("Content-Type", "text/html; charset=utf-8") if request.Method != http.MethodGet {
_, _ = fmt.Fprintf(writer, "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>webFileBrowser</title></head><body><main><h1>webFileBrowser</h1><p>File browser is being prepared.</p><p>Signed in as %s.</p><form method=\"post\" action=\"/logout\"><button type=\"submit\">Sign out</button></form></main></body></html>", template.HTMLEscapeString(user.Email)) 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) { 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) { func renderLogin(writer http.ResponseWriter, status int, message string) {
writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(status) writer.WriteHeader(status)
@ -213,3 +357,11 @@ func renderChangePassword(writer http.ResponseWriter, status int, message string
writer.WriteHeader(status) writer.WriteHeader(status)
_ = changePasswordTemplate.Execute(writer, struct{ Error string }{Error: message}) _ = 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)
}
}

120
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
}
Loading…
Cancel
Save