14 changed files with 666 additions and 17 deletions
@ -0,0 +1,80 @@ |
|||
# HANDOFF |
|||
|
|||
Append-only role handoff log. Each role adds one entry when its step is complete. |
|||
|
|||
## Entry Template |
|||
|
|||
Each entry uses this exact structure. Omit fields marked as role-specific when they do not apply. |
|||
|
|||
--- |
|||
|
|||
### <TASK_ID> — <ROLE> — <YYYY-MM-DDTHH:MM:SSZ> |
|||
|
|||
| Field | Value | |
|||
|-------|-------| |
|||
| Agent | claude \| codex | |
|||
| Summary | One-sentence description of work done | |
|||
| Files Changed | Comma-separated list of changed files | |
|||
| Validation | Commands run and outcomes (implement only) | |
|||
| Commit | `<conventional commit message>` on `next_task`; `<hash> <message>` on `commit_task` (implement only) | |
|||
| Verdict | PASS \| PASS_WITH_NOTES \| FAIL (review only) | |
|||
| Blocking Findings | Numbered list or "none" (review only) | |
|||
| Next Role | plan \| implement \| review \| none | |
|||
|
|||
--- |
|||
|
|||
### T-001 — plan — 2026-09-24T08:54:39Z |
|||
|
|||
| Field | Value | |
|||
|-------|-------| |
|||
| Agent | claude | |
|||
| Summary | Refined ROADMAP.md with the user (Go backend, SQLite, server-rendered UI, config-file-declared multi-root mounts, reverse-proxy TLS assumption), then wrote `.ai/PLAN.md` and `.ai/TASKS.md` with a 6-task sequential breakdown (T-001..T-006) covering scaffolding, auth/forced-password-change, browsing UI, download/streaming with Range support, admin user management, and packaging/docs. | |
|||
| Files Changed | ROADMAP.md, .ai/PLAN.md, .ai/TASKS.md, .ai/HANDOFF.md | |
|||
| Validation | n/a (planning only, no code changed) | |
|||
| Commit | n/a (planner does not commit) | |
|||
| Next Role | implement | |
|||
|
|||
--- |
|||
|
|||
### T-001 — dev — 2026-09-24T09:04:30Z |
|||
|
|||
| Field | Value | |
|||
|-------|-------| |
|||
| Agent | codex | |
|||
| Summary | Added the Go/Docker scaffold with validated named-root configuration from a file or environment variable, plus deployment documentation. | |
|||
| Files Changed | .dockerignore, Dockerfile, README.md, cmd/webfilebrowser/main.go, compose.yaml, config/roots.example.json, go.mod, internal/config/config.go, internal/config/config_test.go, .ai/TASKS.md, .ai/REVIEW.md, .ai/HANDOFF.md | |
|||
| Validation | `go fmt ./...`, `go vet ./...`, and `go test ./...` passed in `golang:1.24`; Docker image build and sample-config startup-log smoke test passed; E2E not configured. | |
|||
| Commit | `feat(scaffold): add container root configuration` | |
|||
| Verdict | PASS | |
|||
| Blocking Findings | none | |
|||
| Next Role | human approval | |
|||
|
|||
--- |
|||
|
|||
### T-001 — dev — 2026-09-24T09:06:08Z |
|||
|
|||
| Field | Value | |
|||
|-------|-------| |
|||
| Agent | codex | |
|||
| Summary | Completed the T-001 review validation after resolving the bind-mounted container's VCS-stamping build check. | |
|||
| Files Changed | .ai/TASKS.md, .ai/REVIEW.md, .ai/HANDOFF.md | |
|||
| Validation | `go build -buildvcs=false ./...` passed in `golang:1.24`; the standard build command's VCS-ownership failure and retry are recorded in `.ai/REVIEW.md`. | |
|||
| Commit | `feat(scaffold): add container root configuration` | |
|||
| Verdict | PASS | |
|||
| Blocking Findings | none | |
|||
| Next Role | human approval | |
|||
|
|||
--- |
|||
|
|||
### T-001 — commit_task — 2026-09-24T09:09:35Z |
|||
|
|||
| Field | Value | |
|||
|-------|-------| |
|||
| Agent | codex | |
|||
| Summary | Approved the reviewed T-001 scaffold and prepared its single task commit. | |
|||
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md | |
|||
| Validation | Review validation recorded in `.ai/REVIEW.md`. | |
|||
| Commit | `feat(scaffold): add container root configuration` | |
|||
| Next Role | none | |
|||
|
|||
--- |
|||
@ -0,0 +1,108 @@ |
|||
# Plan |
|||
|
|||
Status: **active** |
|||
|
|||
Goal: implement the scope defined in `ROADMAP.md` — a small, login-gated, |
|||
container-based file browser that lets an admin mount arbitrary host folders, |
|||
browse them in a light/dark web UI, and download or stream video files with |
|||
zero server-side transcoding. |
|||
|
|||
## Scope |
|||
|
|||
- Go backend, SQLite for users/sessions, server-rendered HTML templates + |
|||
minimal vanilla JS, single Docker image, plain HTTP behind a reverse proxy. |
|||
- Zero unauthenticated access to any route except the login page: login is |
|||
gated first, then a mandatory password-change gate for any account with |
|||
`must_change_password=true` (including the seeded admin), then file |
|||
browsing/download/streaming for both `admin` and `user` roles. |
|||
- Multiple named roots are Docker volume mounts declared in a config |
|||
file/env var (container path + display name), read at startup; adding a |
|||
root requires a config edit + container restart (documented limitation, |
|||
not solved in-app). |
|||
- Read-only browsing: list folders/files, breadcrumb navigation, strict |
|||
containment inside each root (no traversal outside the mount, no symlink |
|||
escape). |
|||
- Download and video streaming serve raw bytes with full HTTP `Range` |
|||
support; no transcoding, thumbnailing, or format conversion anywhere. |
|||
- Admin-only user management: create/list/reset-password/disable users; |
|||
non-admin gets 403 on admin routes. |
|||
- Documentation: `README.md` is kept current with every behavior/interface/ |
|||
workflow/config change in the same commit that makes the change (no |
|||
follow-up doc tasks). |
|||
|
|||
## Out of scope (this cycle) |
|||
|
|||
- Per-user/per-root permission restrictions. |
|||
- Any write operations from the UI (upload/rename/delete/move). |
|||
- Cross-folder search. |
|||
- App-managed TLS (assumed reverse proxy). |
|||
|
|||
## Acceptance Criteria |
|||
|
|||
- No file listing, file content, download, stream, or admin route is ever |
|||
reachable without a valid authenticated session — verified by an explicit |
|||
test hitting each such route unauthenticated and expecting a redirect/401, |
|||
never data. |
|||
- Login with the seeded admin (`boss@zecevic-ninja.com` / `Start1234!`) |
|||
forces a password change before any other page is reachable, and this same |
|||
forced-change gate applies to any user created later with a temporary |
|||
password. |
|||
- Both `admin` and `user` roles can browse, download, and stream all |
|||
configured roots; only `admin` can reach user-management screens. |
|||
- Downloaded file bytes are byte-identical to the source file; video |
|||
`Range` requests return `206 Partial Content` with correct byte ranges, |
|||
enabling native browser seeking with no server-side transcoding. |
|||
- Path handling cannot escape a configured root via `../` traversal or |
|||
symlink. |
|||
- `docker build` + `docker run` (with a sample volume mount and root config) |
|||
produces a working, login-gated instance, and `README.md` is sufficient |
|||
for an operator to deploy it without reading source. |
|||
|
|||
## Implementation Phases |
|||
|
|||
### Phase 1 — Scaffolding & config (T-001) |
|||
- Go module layout, root-config loading (container path + display name from |
|||
file/env), Dockerfile skeleton, docker-compose example, README skeleton. |
|||
|
|||
### Phase 2 — Data & auth foundation (T-002) |
|||
- SQLite schema/migrations for users + sessions, bcrypt hashing, seed admin |
|||
with `must_change_password=true`, session-cookie middleware, login/logout, |
|||
forced password-change gate blocking all other routes. |
|||
|
|||
### Phase 3 — Folder browsing UI (T-003) |
|||
- Root listing, directory navigation with breadcrumbs, strict path |
|||
containment, light/dark theme, minimal vanilla JS/CSS. |
|||
|
|||
### Phase 4 — Download & video streaming (T-004) |
|||
- Raw-byte download endpoint, full HTTP Range support for video seek/resume, |
|||
zero transcoding. |
|||
|
|||
### Phase 5 — Admin user management (T-005) |
|||
- Admin-only list/create/reset/disable-user screens, role-based route |
|||
protection (403 for non-admin). |
|||
|
|||
### Phase 6 — Packaging & docs finalization (T-006) |
|||
- Finalize multi-stage Dockerfile and docker-compose example, complete |
|||
`README.md` end to end (build/run, root config format, first-login/admin |
|||
credential flow, forced password change, reverse-proxy TLS + Range |
|||
passthrough requirement). |
|||
|
|||
Tasks are sequential (T-001 → T-006); each phase's acceptance criteria and |
|||
required documentation updates are tracked per-task in `.ai/TASKS.md`. |
|||
|
|||
## Validation |
|||
|
|||
Run before finishing each task, broader pass before commit: |
|||
- `go fmt ./...` |
|||
- `go vet ./...` |
|||
- `go test ./...` |
|||
|
|||
Task-specific manual/E2E checks (documented per task in `.ai/TASKS.md`): |
|||
- Unauthenticated access attempts against every protected route. |
|||
- Forced password-change flow for the seeded admin and for a newly created |
|||
user. |
|||
- Path-traversal / symlink-escape attempts against the browsing routes. |
|||
- Checksum comparison of a downloaded file against its source. |
|||
- `Range`-header request against a video file, confirming `206 Partial |
|||
Content` and correct byte range. |
|||
- `docker build` + `docker run` smoke test with a sample mounted folder. |
|||
@ -0,0 +1,29 @@ |
|||
# Review Log |
|||
|
|||
## T-001 — 2026-09-24T09:04:30Z |
|||
|
|||
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 build --tag webfilebrowser:t001 .`: PASS |
|||
- Sample-config container startup: PASS; logged `Media` at `/mnt/media` and started on `:8080`. |
|||
- E2E: NOT_CONFIGURED; project documentation declares no E2E command. |
|||
|
|||
Mechanical validation retry 1: |
|||
|
|||
- `go build ./...` in a root-owned `golang:1.24` container: FAIL because Go's |
|||
VCS metadata lookup invokes Git on the bind-mounted repository, which Git |
|||
rejects as an ownership mismatch. The next attempt disables only VCS |
|||
stamping (`-buildvcs=false`); it still compiles every package. |
|||
- `go build -buildvcs=false ./...` in `golang:1.24`: PASS. |
|||
|
|||
The implementation matches T-001: it supplies a Go module and HTTP-server |
|||
skeleton, validated file/environment root loading, an example Compose setup and |
|||
Docker image, and operator documentation for root configuration and restart |
|||
requirements. |
|||
@ -0,0 +1,28 @@ |
|||
# TASKS |
|||
|
|||
Use this board to coordinate handoff between planner, implementer, and reviewer. |
|||
|
|||
Status values: |
|||
- `in_planning` |
|||
- `ready_for_implement` |
|||
- `in_implementation` |
|||
- `ready_for_review` |
|||
- `in_review` |
|||
- `ready_to_commit` |
|||
- `changes_requested` |
|||
- `done` |
|||
|
|||
Command expectations: |
|||
- planner moves tasks into `in_planning` and `ready_for_implement` |
|||
- implementer moves tasks into `in_implementation`, `ready_for_review`, and `done`, and resumes work from `changes_requested` and `ready_to_commit` |
|||
- reviewer moves tasks into `in_review`, `ready_to_commit`, or `changes_requested` |
|||
- `status_cycle` should report deterministic task status, current owner role, and next recommended action based on this board |
|||
|
|||
| Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role | |
|||
| --- | --- | --- | --- | --- | --- | |
|||
| 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 | ready_for_implement | 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 | 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) | 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-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 | |
|||
@ -0,0 +1,4 @@ |
|||
.git |
|||
.ai |
|||
.agents |
|||
.codex |
|||
@ -0,0 +1,23 @@ |
|||
FROM golang:1.24-alpine AS build |
|||
|
|||
WORKDIR /src |
|||
|
|||
COPY go.mod ./ |
|||
RUN go mod download |
|||
|
|||
COPY . ./ |
|||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/webfilebrowser ./cmd/webfilebrowser |
|||
|
|||
FROM alpine:3.21 |
|||
|
|||
RUN addgroup -S webfilebrowser && adduser -S -G webfilebrowser webfilebrowser |
|||
|
|||
COPY --from=build /out/webfilebrowser /usr/local/bin/webfilebrowser |
|||
|
|||
USER webfilebrowser |
|||
|
|||
EXPOSE 8080 |
|||
|
|||
ENV WEB_FILE_BROWSER_ADDR=:8080 |
|||
|
|||
ENTRYPOINT ["/usr/local/bin/webfilebrowser"] |
|||
@ -1,29 +1,101 @@ |
|||
# ROADMAP |
|||
|
|||
Goal: define and deliver the scope for this cycle. |
|||
Goal: build a small, lightweight, container-based file browser web app that lets |
|||
an admin mount arbitrary host folders and browse/stream/download their contents |
|||
through a login-gated web UI, in light and dark themes. |
|||
|
|||
Delete any unused example sections below. Only the Goal and one concrete priority are required. |
|||
## Priority 1 — Authentication & access control |
|||
|
|||
## Priority 1 |
|||
Objective: absolutely no content, file listing, download, or streaming is |
|||
reachable without an authenticated session. Login is the only unauthenticated |
|||
page/route (plus static login assets). |
|||
|
|||
Objective: replace with objective. |
|||
- Login page (username/password) is the only page reachable while |
|||
unauthenticated; every other route (file listing, file content, download, |
|||
stream, admin) requires a valid server-side session. |
|||
- Server-side session cookie: random session ID, HTTP-only + Secure + |
|||
SameSite cookie, session record (with expiry, e.g. 7-day sliding window) |
|||
stored in SQLite. Logout deletes the session server-side. |
|||
- Passwords hashed with bcrypt (or equivalent), never stored/logged in |
|||
plaintext. |
|||
- Seed data on first startup: admin user `boss@zecevic-ninja.com`, |
|||
password `Start1234!`, flagged `must_change_password = true`. |
|||
- Any account (seeded admin or created user) with `must_change_password = |
|||
true` is redirected to a forced "change your password" screen immediately |
|||
after login and cannot reach any other page (including file browsing) |
|||
until the password is changed. New password must differ from the |
|||
temporary/old one and meet a minimum strength rule (documented in the |
|||
plan). |
|||
- Two roles: `admin` and `user`. Both roles can browse/download/stream all |
|||
configured roots (no per-user folder restrictions in this cycle). Only |
|||
`admin` can access the user-management screens (create user, reset a |
|||
user's password / force `must_change_password`, delete/disable a user). |
|||
- App assumes it runs behind a reverse proxy (nginx/Traefik/Caddy) for |
|||
TLS; the app itself serves plain HTTP inside the container. This |
|||
assumption and the required reverse-proxy config (incl. passing through |
|||
HTTP Range headers) must be documented in `README.md`. |
|||
|
|||
- Replace with planned outcome. |
|||
## Priority 2 — Folder browsing UI |
|||
|
|||
## Examples |
|||
Objective: a light, simple, low-dependency UI (server-rendered HTML + |
|||
minimal vanilla JS, no SPA build pipeline) with light/dark theme, for |
|||
browsing configured root folders. |
|||
|
|||
These example sections are optional illustrations, not required structure. |
|||
- Multiple named roots are configurable via a config file / env var read at |
|||
container startup (each entry: container path + display name). Roots are |
|||
fixed host-to-container Docker volume mounts; adding/removing a root |
|||
requires editing the config and restarting the container (a Docker/host |
|||
constraint, not an app limitation) — this must be documented clearly in |
|||
`README.md`. |
|||
- After login, user sees the list of configured roots, can enter |
|||
subfolders, see breadcrumb navigation, and go back up. All path handling |
|||
must be strictly confined inside each root (no path traversal outside the |
|||
mounted root, symlink escapes rejected). |
|||
- Folder view lists files and subfolders (name, type, size, modified time |
|||
for files). |
|||
- Theme: light and dark mode, user-toggleable, minimal/clean styling |
|||
(no heavy CSS/JS framework). |
|||
|
|||
<!-- Example: remove or replace this section --> |
|||
## Priority 2 |
|||
## Priority 3 — File download & video streaming (no transcoding) |
|||
|
|||
Objective: optional second objective. |
|||
Objective: allow downloading any file and streaming video files directly, |
|||
with zero server-side transcoding/encoding (server is slow), by serving raw |
|||
bytes with HTTP Range support. |
|||
|
|||
- Replace with optional planned outcome. |
|||
- Download endpoint streams the raw file bytes as-is with correct |
|||
`Content-Type` / `Content-Disposition`. |
|||
- Video files are served with full HTTP `Range` request support (seek, |
|||
partial content, resumable) so the browser's native `<video>` player can |
|||
stream/seek without any server-side re-encoding. |
|||
- No media processing (no thumbnailing, no transcoding, no format |
|||
conversion) is performed anywhere in this cycle. |
|||
|
|||
<!-- Example: remove or replace this section --> |
|||
## Priority 3 |
|||
## Priority 4 — Admin: user management |
|||
|
|||
Objective: optional third objective. |
|||
Objective: admin can manage user accounts from the web UI. |
|||
|
|||
- Replace with optional planned outcome. |
|||
- Admin-only screens: list users, create user (sets a temporary password, |
|||
`must_change_password = true`), reset a user's password, disable/delete a |
|||
user. |
|||
- User management routes are protected the same way as everything else: |
|||
unauthenticated access is impossible, and non-admin authenticated users |
|||
get a 403, not a redirect that leaks existence of the page. |
|||
|
|||
## Cross-cutting |
|||
|
|||
- Tech stack: Go backend (per `AGENTS.md` validation commands), SQLite for |
|||
users/sessions, server-rendered HTML templates + minimal vanilla JS |
|||
frontend, single Docker container image. |
|||
- Documentation: `README.md` must cover container build/run, volume mount |
|||
+ root config format, first-login/admin credential flow, forced password |
|||
change, and the reverse-proxy/TLS + Range-header-passthrough requirement. |
|||
Documentation updates are in-scope for every task that touches these |
|||
behaviors, not a follow-up. |
|||
|
|||
## Open questions for later cycles (explicitly out of scope for now) |
|||
|
|||
- Per-user/per-root permission restrictions (currently: all authenticated |
|||
users see all roots). |
|||
- File upload, rename, delete, or any write operations from the UI |
|||
(currently: read-only browsing/download/streaming). |
|||
- Search across folders. |
|||
|
|||
@ -0,0 +1,41 @@ |
|||
// Command webfilebrowser starts the HTTP server for the file browser.
|
|||
package main |
|||
|
|||
import ( |
|||
"errors" |
|||
"log/slog" |
|||
"net/http" |
|||
"os" |
|||
|
|||
"webfilebrowser/internal/config" |
|||
) |
|||
|
|||
const defaultAddress = ":8080" |
|||
|
|||
func main() { |
|||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) |
|||
applicationConfig, err := config.Load() |
|||
if err != nil { |
|||
logger.Error("load root configuration", "error", err) |
|||
os.Exit(1) |
|||
} |
|||
|
|||
for _, root := range applicationConfig.Roots { |
|||
logger.Info("configured browse root", "name", root.Name, "path", root.Path) |
|||
} |
|||
|
|||
address := os.Getenv("WEB_FILE_BROWSER_ADDR") |
|||
if address == "" { |
|||
address = defaultAddress |
|||
} |
|||
|
|||
server := &http.Server{ |
|||
Addr: address, |
|||
Handler: http.NewServeMux(), |
|||
} |
|||
logger.Info("starting web file browser", "address", address) |
|||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { |
|||
logger.Error("serve HTTP", "error", err) |
|||
os.Exit(1) |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
services: |
|||
webfilebrowser: |
|||
build: . |
|||
ports: |
|||
- "8080:8080" |
|||
environment: |
|||
WEB_FILE_BROWSER_ROOTS_FILE: /etc/webfilebrowser/roots.json |
|||
volumes: |
|||
- ./config/roots.example.json:/etc/webfilebrowser/roots.json:ro |
|||
- ${MEDIA_DIR:-./example-files}:/mnt/media:ro |
|||
@ -0,0 +1,8 @@ |
|||
{ |
|||
"roots": [ |
|||
{ |
|||
"name": "Media", |
|||
"path": "/mnt/media" |
|||
} |
|||
] |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
module webfilebrowser |
|||
|
|||
go 1.24.0 |
|||
@ -0,0 +1,101 @@ |
|||
// Package config loads the file-system roots that the application exposes.
|
|||
package config |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"os" |
|||
"path/filepath" |
|||
"strings" |
|||
) |
|||
|
|||
const ( |
|||
rootsEnvVar = "WEB_FILE_BROWSER_ROOTS" |
|||
rootsFileEnvVar = "WEB_FILE_BROWSER_ROOTS_FILE" |
|||
) |
|||
|
|||
// Root is a named, absolute path inside the application container.
|
|||
type Root struct { |
|||
Name string `json:"name"` |
|||
Path string `json:"path"` |
|||
} |
|||
|
|||
// Config contains all configured browse roots.
|
|||
type Config struct { |
|||
Roots []Root |
|||
} |
|||
|
|||
type fileConfig struct { |
|||
Roots []Root `json:"roots"` |
|||
} |
|||
|
|||
// Load reads root configuration from WEB_FILE_BROWSER_ROOTS, when set, or
|
|||
// from the JSON file named by WEB_FILE_BROWSER_ROOTS_FILE. The environment
|
|||
// value is a JSON array of roots and has precedence over the file.
|
|||
func Load() (Config, error) { |
|||
if rootsJSON := strings.TrimSpace(os.Getenv(rootsEnvVar)); rootsJSON != "" { |
|||
roots, err := decodeRoots([]byte(rootsJSON)) |
|||
if err != nil { |
|||
return Config{}, fmt.Errorf("parse %s: %w", rootsEnvVar, err) |
|||
} |
|||
return Config{Roots: roots}, nil |
|||
} |
|||
|
|||
configPath := strings.TrimSpace(os.Getenv(rootsFileEnvVar)) |
|||
if configPath == "" { |
|||
return Config{}, fmt.Errorf("set %s or %s", rootsEnvVar, rootsFileEnvVar) |
|||
} |
|||
|
|||
contents, err := os.ReadFile(configPath) |
|||
if err != nil { |
|||
return Config{}, fmt.Errorf("read %s: %w", configPath, err) |
|||
} |
|||
|
|||
var fromFile fileConfig |
|||
if err := json.Unmarshal(contents, &fromFile); err != nil { |
|||
return Config{}, fmt.Errorf("parse %s: %w", configPath, err) |
|||
} |
|||
if err := validateRoots(fromFile.Roots); err != nil { |
|||
return Config{}, err |
|||
} |
|||
|
|||
return Config{Roots: fromFile.Roots}, nil |
|||
} |
|||
|
|||
func decodeRoots(contents []byte) ([]Root, error) { |
|||
var roots []Root |
|||
if err := json.Unmarshal(contents, &roots); err != nil { |
|||
return nil, err |
|||
} |
|||
if err := validateRoots(roots); err != nil { |
|||
return nil, err |
|||
} |
|||
return roots, nil |
|||
} |
|||
|
|||
func validateRoots(roots []Root) error { |
|||
if len(roots) == 0 { |
|||
return fmt.Errorf("at least one root is required") |
|||
} |
|||
|
|||
names := make(map[string]struct{}, len(roots)) |
|||
for index := range roots { |
|||
roots[index].Name = strings.TrimSpace(roots[index].Name) |
|||
roots[index].Path = strings.TrimSpace(roots[index].Path) |
|||
|
|||
if roots[index].Name == "" { |
|||
return fmt.Errorf("root %d has an empty name", index+1) |
|||
} |
|||
if !filepath.IsAbs(roots[index].Path) { |
|||
return fmt.Errorf("root %q path must be absolute", roots[index].Name) |
|||
} |
|||
|
|||
nameKey := strings.ToLower(roots[index].Name) |
|||
if _, exists := names[nameKey]; exists { |
|||
return fmt.Errorf("root name %q is duplicated", roots[index].Name) |
|||
} |
|||
names[nameKey] = struct{}{} |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
@ -0,0 +1,95 @@ |
|||
package config |
|||
|
|||
import ( |
|||
"os" |
|||
"path/filepath" |
|||
"reflect" |
|||
"testing" |
|||
) |
|||
|
|||
func TestLoadUsesRootsFromEnvironment(t *testing.T) { |
|||
t.Setenv(rootsEnvVar, `[{"name":"Media","path":"/mnt/media"},{"name":"Archive","path":"/mnt/archive"}]`) |
|||
t.Setenv(rootsFileEnvVar, "") |
|||
|
|||
config, err := Load() |
|||
if err != nil { |
|||
t.Fatalf("Load() error = %v", err) |
|||
} |
|||
|
|||
want := []Root{ |
|||
{Name: "Media", Path: "/mnt/media"}, |
|||
{Name: "Archive", Path: "/mnt/archive"}, |
|||
} |
|||
if !reflect.DeepEqual(config.Roots, want) { |
|||
t.Errorf("Load().Roots = %#v, want %#v", config.Roots, want) |
|||
} |
|||
} |
|||
|
|||
func TestLoadUsesRootsFromFile(t *testing.T) { |
|||
configPath := filepath.Join(t.TempDir(), "roots.json") |
|||
contents := `{ |
|||
"roots": [ |
|||
{"name": "Documents", "path": "/mnt/documents"} |
|||
] |
|||
}` |
|||
if err := os.WriteFile(configPath, []byte(contents), 0o600); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
|
|||
t.Setenv(rootsEnvVar, "") |
|||
t.Setenv(rootsFileEnvVar, configPath) |
|||
|
|||
config, err := Load() |
|||
if err != nil { |
|||
t.Fatalf("Load() error = %v", err) |
|||
} |
|||
|
|||
want := []Root{{Name: "Documents", Path: "/mnt/documents"}} |
|||
if !reflect.DeepEqual(config.Roots, want) { |
|||
t.Errorf("Load().Roots = %#v, want %#v", config.Roots, want) |
|||
} |
|||
} |
|||
|
|||
func TestLoadEnvironmentOverridesRootsFile(t *testing.T) { |
|||
configPath := filepath.Join(t.TempDir(), "roots.json") |
|||
if err := os.WriteFile(configPath, []byte(`{"roots":[{"name":"File root","path":"/mnt/from-file"}]}`), 0o600); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
|
|||
t.Setenv(rootsFileEnvVar, configPath) |
|||
t.Setenv(rootsEnvVar, `[{"name":"Environment root","path":"/mnt/from-environment"}]`) |
|||
|
|||
config, err := Load() |
|||
if err != nil { |
|||
t.Fatalf("Load() error = %v", err) |
|||
} |
|||
|
|||
want := []Root{{Name: "Environment root", Path: "/mnt/from-environment"}} |
|||
if !reflect.DeepEqual(config.Roots, want) { |
|||
t.Errorf("Load().Roots = %#v, want %#v", config.Roots, want) |
|||
} |
|||
} |
|||
|
|||
func TestLoadRejectsInvalidRoots(t *testing.T) { |
|||
t.Setenv(rootsFileEnvVar, "") |
|||
|
|||
tests := []struct { |
|||
name string |
|||
value string |
|||
}{ |
|||
{name: "empty list", value: `[]`}, |
|||
{name: "relative path", value: `[{"name":"Media","path":"relative/path"}]`}, |
|||
{name: "blank name", value: `[{"name":" ","path":"/mnt/media"}]`}, |
|||
{name: "duplicate names", value: `[{"name":"Media","path":"/mnt/media"},{"name":"Media","path":"/mnt/archive"}]`}, |
|||
} |
|||
|
|||
for _, test := range tests { |
|||
t.Run(test.name, func(t *testing.T) { |
|||
t.Setenv(rootsEnvVar, test.value) |
|||
|
|||
if _, err := Load(); err == nil { |
|||
t.Fatal("Load() error = nil, want an error") |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
Loading…
Reference in new issue