From 83fa87a44f9c7213fe7da106cb3b59c9017dda46 Mon Sep 17 00:00:00 2001 From: Domagoj Zecevic Date: Thu, 24 Sep 2026 12:01:20 +0200 Subject: [PATCH] feat(files): add downloads and video streaming --- .ai/HANDOFF.md | 28 +++++++++++ .ai/REVIEW.md | 19 ++++++++ .ai/TASKS.md | 2 +- README.md | 16 ++++++- internal/browser/browser.go | 60 ++++++++++++++++++++++++ internal/browser/file_test.go | 62 ++++++++++++++++++++++++ internal/web/app.go | 72 +++++++++++++++++++++++++--- internal/web/file_test.go | 88 +++++++++++++++++++++++++++++++++++ 8 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 internal/browser/file_test.go create mode 100644 internal/web/file_test.go diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index 7233266..4d6ed07 100644 --- a/.ai/HANDOFF.md +++ b/.ai/HANDOFF.md @@ -134,3 +134,31 @@ Each entry uses this exact structure. Omit fields marked as role-specific when t | Next Role | none | --- + +### T-004 — dev — 2026-09-24T09:59:37Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Added root-confined raw file downloads and HTTP Range video streaming without media conversion. | +| Files Changed | README.md, internal/browser/browser.go, internal/browser/file_test.go, internal/web/app.go, internal/web/file_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(files): add downloads and video streaming` | +| Verdict | PASS | +| Blocking Findings | none | +| Next Role | human approval | + +--- + +### T-004 — commit_task — 2026-09-24T10:00:28Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Approved the reviewed T-004 file-delivery 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(files): add downloads and video streaming` | +| Next Role | none | + +--- diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md index f0ad895..ef38fbe 100644 --- a/.ai/REVIEW.md +++ b/.ai/REVIEW.md @@ -68,3 +68,22 @@ 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. + +## T-004 — 2026-09-24T09:59:37Z + +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 the new download and stream endpoints reuse the +root-confined file opener, serve only regular files, and delegate byte-range +handling to Go's `http.ServeContent`. Tests verify the attachment's unmodified +bytes, content headers, `206 Partial Content`, `Content-Range`, and +`Accept-Ranges` behavior. No media processing code was added. diff --git a/.ai/TASKS.md b/.ai/TASKS.md index de9eafb..f0e599d 100644 --- a/.ai/TASKS.md +++ b/.ai/TASKS.md @@ -23,6 +23,6 @@ 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) | 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 | done | 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 | PASS: root-confined regular-file opens; authenticated byte-identical attachment download; video `206` `Range` response; unauthenticated delivery gate; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24` | none | | 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 608bb67..7c1c9a0 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ ## Overview A lightweight, container-based file browser for named host folders mounted -read-only into a container. It provides login-gated root and folder browsing; -file downloads and streaming are added in a later phase. +read-only into a container. It provides login-gated browsing, downloads, and +native video streaming without server-side media processing. ## Getting Started @@ -94,6 +94,18 @@ 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. +## Downloads and video streaming + +Every listed file has a download link that returns its original bytes as an +attachment. Recognized video files also have a streaming link. The application +uses HTTP byte ranges for video seeking and resumable playback; it never +transcodes, encodes, thumbnails, or otherwise transforms media. + +Your TLS reverse proxy must pass the request `Range` header through to the +application and preserve range responses such as `206 Partial Content`, +`Content-Range`, and `Accept-Ranges`. Do not buffer or replace these responses, +or native browser seeking will not work. + ## AI Workflow This project includes the persistent planner/implementer/reviewer workflow with file-based coordination, plus the PO orchestration layer. diff --git a/internal/browser/browser.go b/internal/browser/browser.go index b4c9c2c..b17b06b 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -36,6 +36,15 @@ type Directory struct { Entries []Entry } +// File describes a regular file that is safe to serve from a configured root. +type File struct { + Root config.Root + RelativePath string + Name string + Size int64 + ModifiedAt time.Time +} + // Browser provides root-confined directory listings. type Browser struct { rootsByName map[string]config.Root @@ -125,6 +134,57 @@ func (browser *Browser) List(rootName, relativePath string) (Directory, error) { return Directory{Root: root, RelativePath: cleanRelativePath, Entries: listedEntries}, nil } +// OpenFile opens a regular file without allowing its path to escape the +// configured root through parent traversal or symlinks. The caller closes the +// returned file after streaming its raw bytes. +func (browser *Browser) OpenFile(rootName, relativePath string) (*os.File, File, error) { + root, exists := browser.rootsByName[rootName] + if !exists { + return nil, File{}, ErrRootNotFound + } + cleanRelativePath, err := cleanRelativePath(relativePath) + if err != nil || cleanRelativePath == "" { + if err != nil { + return nil, File{}, err + } + return nil, File{}, ErrDirectoryNotFound + } + + resolvedRoot, err := filepath.EvalSymlinks(root.Path) + if err != nil { + return nil, File{}, fmt.Errorf("%w: %v", ErrDirectoryNotFound, err) + } + rootInfo, err := os.Stat(resolvedRoot) + if err != nil || !rootInfo.IsDir() { + return nil, File{}, ErrDirectoryNotFound + } + + candidate := filepath.Join(root.Path, filepath.FromSlash(cleanRelativePath)) + resolvedCandidate, err := filepath.EvalSymlinks(candidate) + if err != nil { + return nil, File{}, fmt.Errorf("%w: %v", ErrDirectoryNotFound, err) + } + if !isWithin(resolvedRoot, resolvedCandidate) { + return nil, File{}, ErrPathOutsideRoot + } + info, err := os.Stat(resolvedCandidate) + if err != nil || !info.Mode().IsRegular() { + return nil, File{}, ErrDirectoryNotFound + } + + file, err := os.Open(resolvedCandidate) + if err != nil { + return nil, File{}, fmt.Errorf("open file: %w", err) + } + return file, File{ + Root: root, + RelativePath: cleanRelativePath, + Name: filepath.Base(cleanRelativePath), + Size: info.Size(), + ModifiedAt: info.ModTime(), + }, nil +} + func cleanRelativePath(relativePath string) (string, error) { if relativePath == "" { return "", nil diff --git a/internal/browser/file_test.go b/internal/browser/file_test.go new file mode 100644 index 0000000..d4ad3da --- /dev/null +++ b/internal/browser/file_test.go @@ -0,0 +1,62 @@ +package browser + +import ( + "errors" + "io" + "os" + "path/filepath" + "testing" + + "webfilebrowser/internal/config" +) + +func TestOpenFileReturnsOnlyARegularFileWithinTheConfiguredRoot(t *testing.T) { + rootPath := t.TempDir() + contents := []byte("byte-identical download") + if err := os.WriteFile(filepath.Join(rootPath, "report.txt"), contents, 0o600); err != nil { + t.Fatal(err) + } + browser, err := New([]config.Root{{Name: "Media", Path: rootPath}}) + if err != nil { + t.Fatal(err) + } + + file, metadata, err := browser.OpenFile("Media", "report.txt") + if err != nil { + t.Fatalf("OpenFile() error = %v", err) + } + t.Cleanup(func() { _ = file.Close() }) + actual, err := io.ReadAll(file) + if err != nil { + t.Fatal(err) + } + if string(actual) != string(contents) { + t.Errorf("OpenFile() bytes = %q, want %q", actual, contents) + } + if metadata.Name != "report.txt" || metadata.Size != int64(len(contents)) { + t.Errorf("OpenFile() metadata = %#v, want report.txt with its byte size", metadata) + } +} + +func TestOpenFileRejectsTraversalAndSymlinkEscapes(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(filepath.Join(outsidePath, "secret.txt"), filepath.Join(rootPath, "escape.txt")); 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) + "/secret.txt", "escape.txt"} { + t.Run(relativePath, func(t *testing.T) { + if _, _, err := browser.OpenFile("Media", relativePath); !errors.Is(err, ErrPathOutsideRoot) { + t.Errorf("OpenFile(%q) error = %v, want ErrPathOutsideRoot", relativePath, err) + } + }) + } +} diff --git a/internal/web/app.go b/internal/web/app.go index 399bf56..11eb483 100644 --- a/internal/web/app.go +++ b/internal/web/app.go @@ -4,9 +4,11 @@ package web import ( "errors" "html/template" + "mime" "net/http" "net/url" "path" + "path/filepath" "strconv" "strings" "time" @@ -21,6 +23,8 @@ const ( logoutPath = "/logout" changePasswordPath = "/change-password" browsePath = "/browse" + downloadPath = "/download" + streamPath = "/stream" sessionCookieName = "webfilebrowser_session" ) @@ -41,7 +45,7 @@ var ( 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)) +{{range .Entries}}{{else}}{{end}}
NameTypeSizeModifiedActions
{{if .URL}}{{.Name}}{{else}}{{.Name}}{{end}}{{.Type}}{{.Size}}{{.Modified}}{{if .DownloadURL}}Download{{end}}{{if .StreamURL}} · Stream{{end}}
This folder is empty.
` + signOutForm + pageEnd)) ) const pageStart = `webFileBrowser