Browse Source

feat(scaffold): add container root configuration

main
Domagoj Zecevic 1 month ago
parent
commit
b6f7f8f615
  1. 80
      .ai/HANDOFF.md
  2. 108
      .ai/PLAN.md
  3. 29
      .ai/REVIEW.md
  4. 28
      .ai/TASKS.md
  5. 4
      .dockerignore
  6. 23
      Dockerfile
  7. 51
      README.md
  8. 102
      ROADMAP.md
  9. 41
      cmd/webfilebrowser/main.go
  10. 10
      compose.yaml
  11. 8
      config/roots.example.json
  12. 3
      go.mod
  13. 101
      internal/config/config.go
  14. 95
      internal/config/config_test.go

80
.ai/HANDOFF.md

@ -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 |
---

108
.ai/PLAN.md

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

29
.ai/REVIEW.md

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

28
.ai/TASKS.md

@ -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 |

4
.dockerignore

@ -0,0 +1,4 @@
.git
.ai
.agents
.codex

23
Dockerfile

@ -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"]

51
README.md

@ -2,11 +2,58 @@
## Overview ## Overview
TODO: Describe this project. 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
the configured roots; login, folder browsing, and file delivery are added in
later phases.
## Getting Started ## Getting Started
TODO: Add setup instructions. Docker is the only prerequisite for the scaffold.
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:
```bash
docker compose up --build
```
The server listens on `http://localhost:8080` and logs every configured root
at startup. The scaffold intentionally has no application page yet, so requests
currently receive `404 Not Found`.
## Root configuration
Set `WEB_FILE_BROWSER_ROOTS_FILE` to a JSON configuration file containing a
`roots` array. Each root has a human-readable `name` and an absolute `path`
inside the container:
```json
{
"roots": [
{"name": "Media", "path": "/mnt/media"},
{"name": "Documents", "path": "/mnt/documents"}
]
}
```
Alternatively, set `WEB_FILE_BROWSER_ROOTS` to a JSON array of the same root
objects. This environment variable takes precedence over
`WEB_FILE_BROWSER_ROOTS_FILE`:
```bash
WEB_FILE_BROWSER_ROOTS='[{"name":"Media","path":"/mnt/media"}]' \
docker compose up --build
```
Each configured container path must be supplied by a Docker volume mount. To
add or remove a root, update both the configuration and the container's volume
mounts, then restart the container. Roots are intentionally fixed at startup;
they cannot be changed from the application.
## AI Workflow ## AI Workflow

102
ROADMAP.md

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

41
cmd/webfilebrowser/main.go

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

10
compose.yaml

@ -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

8
config/roots.example.json

@ -0,0 +1,8 @@
{
"roots": [
{
"name": "Media",
"path": "/mnt/media"
}
]
}

3
go.mod

@ -0,0 +1,3 @@
module webfilebrowser
go 1.24.0

101
internal/config/config.go

@ -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
}

95
internal/config/config_test.go

@ -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…
Cancel
Save