diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index cbaec8b..949eaaf 100644 --- a/.ai/HANDOFF.md +++ b/.ai/HANDOFF.md @@ -190,3 +190,31 @@ Each entry uses this exact structure. Omit fields marked as role-specific when t | Next Role | none | --- + +### T-006 — dev — 2026-09-24T10:25:20Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Finalized the production container deployment path with a minimized build context, loopback-bound Compose service, complete operator instructions, and an end-to-end smoke test. | +| Files Changed | .dockerignore, README.md, compose.yaml, scripts/test-production-deployment.sh, .ai/TASKS.md, .ai/REVIEW.md, .ai/HANDOFF.md | +| Validation | `go fmt ./...`, `go vet ./...`, and `go test ./...` passed in `golang:1.24`; `docker compose config` and `bash scripts/test-production-deployment.sh` passed. | +| Commit | `feat(deployment): add production container setup` | +| Verdict | PASS | +| Blocking Findings | none | +| Next Role | human approval | + +--- + +### T-006 — commit_task — 2026-09-24T10:26:29Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Approved the reviewed T-006 production deployment 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(deployment): add production container setup` | +| Next Role | none | + +--- diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md index 3c4e4b3..9295a1f 100644 --- a/.ai/REVIEW.md +++ b/.ai/REVIEW.md @@ -108,3 +108,27 @@ for an authenticated standard user. The new storage operations use bcrypt, force a password change after creates and resets, and invalidate sessions on resets and disables. README documents the administrator workflow and the standard-user boundary. + +## T-006 — 2026-09-24T10:25:20Z + +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 +- `docker compose config`: PASS; confirms loopback-only port publishing, + read-only configuration and root mounts, persistent SQLite volume, and the + restart policy. +- `bash scripts/test-production-deployment.sh`: PASS; builds the image, + verifies non-root runtime/no Go binary, serves `/login`, and redirects an + unauthenticated root request to login. + +The review confirms that the final Alpine stage retains no Go toolchain, the +build context excludes repository-only artifacts, and both documented launch +paths preserve the root-configuration and persistent-data mounts. README now +covers Compose and Docker Run deployment, first-login administration, the +forced-password-change flow, and reverse-proxy TLS and Range requirements. diff --git a/.ai/TASKS.md b/.ai/TASKS.md index 4dc76e1..72bd86f 100644 --- a/.ai/TASKS.md +++ b/.ai/TASKS.md @@ -25,4 +25,4 @@ Command expectations: | 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 | 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) | done | 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 | PASS: admin lifecycle/session-invalidation and HTTP role-protection/temporary-password tests; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24`; E2E not configured | none | -| 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) | done | `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 | PASS: Compose configuration validation; production image build/run, non-root runtime, and unauthenticated login boundary smoke test; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24` | none | diff --git a/.dockerignore b/.dockerignore index 1832b24..22998c8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,12 @@ .ai .agents .codex +.claude +AGENTS.md +CLAUDE.md +ROADMAP.md +ROADMAP.template.md +README.md +compose.yaml +config +scripts diff --git a/README.md b/README.md index 6f80143..3eae1d3 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,51 @@ A lightweight, container-based file browser for named host folders mounted read-only into a container. It provides login-gated browsing, downloads, and native video streaming without server-side media processing. -## Getting Started +## Deploy with Docker Compose -Docker is the only prerequisite for the scaffold. +Docker and the Docker Compose plugin are the only prerequisites for the +included deployment configuration. 1. Update [`config/roots.example.json`](config/roots.example.json) with each root's display name and its absolute path *inside the container*. 2. Mount the matching host folders at those paths. The included [`compose.yaml`](compose.yaml) maps `${MEDIA_DIR:-./example-files}` to - `/mnt/media` as an example. -3. Start the service: + `/mnt/media` as an example. Set `MEDIA_DIR` to an absolute host path for a + production deployment. +3. Start the service in the background: ```bash - docker compose up --build + MEDIA_DIR=/srv/webfilebrowser/media docker compose up --build -d ``` -The server listens on `http://localhost:8080` and logs every configured root -at startup. `/login` is the only public application route; every other route -redirects to login until it has a valid server-side session. +The Compose service restarts unless stopped explicitly. It persists SQLite data +in the `webfilebrowser-data` named volume and mounts every configured browse +root read-only. The server listens on `http://localhost:8080` and logs every +configured root at startup. `/login` is the only public application route; +every other route redirects to login until it has a valid server-side session. + +## Deploy with Docker Run + +To use the image without Compose, build it, create the persistent data volume, +and mount both the roots configuration and the host folders named by it: + +```bash +docker build --tag webfilebrowser:latest . +docker volume create webfilebrowser-data +MEDIA_DIR=/srv/webfilebrowser/media +docker run --detach --name webfilebrowser --restart unless-stopped \ + --publish 127.0.0.1:8080:8080 \ + --env WEB_FILE_BROWSER_ROOTS_FILE=/etc/webfilebrowser/roots.json \ + --volume "$PWD/config/roots.example.json:/etc/webfilebrowser/roots.json:ro" \ + --volume "$MEDIA_DIR:/mnt/media:ro" \ + --volume webfilebrowser-data:/var/lib/webfilebrowser \ + webfilebrowser:latest +``` + +The image runs as an unprivileged `webfilebrowser` user and contains only the +compiled application and its Alpine runtime dependencies; Go and build tools +remain in the multi-stage build image. Keep the data volume when recreating or +upgrading the container so accounts and sessions persist. ## Root configuration @@ -77,7 +104,9 @@ server-side, use a seven-day sliding expiry, and are invalidated on logout. 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. +send the secure cookie over plain HTTP. Bind the application to loopback as in +the Docker Run example, terminate TLS at the reverse proxy, and forward the +original `Host` and `Range` request headers to the application. ## User administration @@ -121,6 +150,20 @@ 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. +## Deployment smoke test + +Run the documented end-to-end deployment smoke test after changing container +packaging or before promoting an image: + +```bash +bash scripts/test-production-deployment.sh +``` + +It builds the image, starts an isolated container with temporary configuration, +media, and SQLite-volume mounts, verifies that the login page is public and +the root page redirects unauthenticated requests, then removes the temporary +container and volume. + ## AI Workflow This project includes the persistent planner/implementer/reviewer workflow with file-based coordination, plus the PO orchestration layer. diff --git a/compose.yaml b/compose.yaml index 08d7db1..abeab68 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,8 +1,9 @@ services: webfilebrowser: build: . + restart: unless-stopped ports: - - "8080:8080" + - "127.0.0.1:8080:8080" environment: WEB_FILE_BROWSER_ROOTS_FILE: /etc/webfilebrowser/roots.json volumes: diff --git a/scripts/test-production-deployment.sh b/scripts/test-production-deployment.sh new file mode 100644 index 0000000..8f3608d --- /dev/null +++ b/scripts/test-production-deployment.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Build an isolated runtime image and verify its public authentication boundary. +set -euo pipefail + +image_tag="${IMAGE_TAG:-webfilebrowser:e2e}" +test_suffix="${RANDOM}-$$" +container_name="webfilebrowser-e2e-${test_suffix}" +volume_name="webfilebrowser-e2e-data-${test_suffix}" +work_directory="$(mktemp -d)" + +cleanup() { + docker rm --force "${container_name}" >/dev/null 2>&1 || true + docker volume rm --force "${volume_name}" >/dev/null 2>&1 || true + rm -rf "${work_directory}" +} +trap cleanup EXIT + +mkdir -p "${work_directory}/media" +cp config/roots.example.json "${work_directory}/roots.json" + +docker build --tag "${image_tag}" . +docker volume create "${volume_name}" >/dev/null +docker run --detach --rm --name "${container_name}" \ + --publish 127.0.0.1::8080 \ + --env WEB_FILE_BROWSER_ROOTS_FILE=/etc/webfilebrowser/roots.json \ + --volume "${work_directory}/roots.json:/etc/webfilebrowser/roots.json:ro" \ + --volume "${work_directory}/media:/mnt/media:ro" \ + --volume "${volume_name}:/var/lib/webfilebrowser" \ + "${image_tag}" >/dev/null + +port="$(docker port "${container_name}" 8080/tcp | sed -n '1s/.*://p')" +base_url="http://127.0.0.1:${port}" +headers_file="${work_directory}/headers" +body_file="${work_directory}/body" + +for _ in {1..30}; do + if curl --silent --output "${body_file}" --dump-header "${headers_file}" "${base_url}/login"; then + break + fi + sleep 1 +done + +if ! rg -q '^HTTP/[0-9.]+ 200' "${headers_file}" || ! rg -q 'Sign in' "${body_file}"; then + docker logs "${container_name}" + echo "container did not serve the login page" >&2 + exit 1 +fi + +curl --silent --show-error --output "${body_file}" --dump-header "${headers_file}" "${base_url}/" +if ! rg -q '^HTTP/[0-9.]+ 303' "${headers_file}" || ! rg -qi '^Location: /login' "${headers_file}"; then + docker logs "${container_name}" + echo "unauthenticated root request was not redirected to login" >&2 + exit 1 +fi + +if [[ "$(docker image inspect --format '{{.Config.User}}' "${image_tag}")" != "webfilebrowser" ]]; then + echo "container image must run as the webfilebrowser user" >&2 + exit 1 +fi +docker run --rm --entrypoint /bin/sh "${image_tag}" -c '! command -v go' + +echo "production deployment smoke test passed"