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 |
# 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 3 — File download & video streaming (no transcoding) |
||||
## Priority 2 |
|
||||
|
|
||||
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 4 — Admin: user management |
||||
## Priority 3 |
|
||||
|
|
||||
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