diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md new file mode 100644 index 0000000..ec0f5f0 --- /dev/null +++ b/.ai/HANDOFF.md @@ -0,0 +1,58 @@ +# HANDOFF + +Append-only role handoff log. Each role adds one entry when its step is complete. + +--- + +### T-001 — implement — 2026-06-18T08:20:22Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Committed T-001 scaffold after review approval. | +| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md | +| Validation | n/a (commit_task) | +| Commit | `pending feat(scaffold): add runnable CamMonitor health service` | +| Next Role | none | + +--- + +### T-001 — review — 2026-06-18T10:20:00Z + +| Field | Value | +|-------|-------| +| Agent | claude | +| Summary | Reviewed T-001 project scaffold; all acceptance criteria met; four nit-level findings, none blocking. | +| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md | +| Validation | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...` all pass; E2E evidence from implementer accepted. | +| Verdict | PASS_WITH_NOTES | +| Blocking Findings | none | +| Next Role | implement | + +--- + +### T-001 — implement — 2026-06-18T08:12:05Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Added the runnable Go scaffold with environment config, SQLite startup, chi health endpoint, Docker Compose deployment, and project documentation. | +| Files Changed | .dockerignore, .env.example, .gitignore, Dockerfile, README.md, cmd/server/main.go, docker-compose.yml, go.mod, go.sum, internal/config/config.go, internal/db/db.go, internal/web/router.go, internal/web/router_test.go, testdata/footage/.gitkeep, .ai/TASKS.md, .ai/HANDOFF.md | +| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./...` passed; `go test -race ./...` passed; `docker compose build` passed; `curl -i http://127.0.0.1:18080/health` returned HTTP 200 and `{"status":"ok"}` | +| Commit | `feat(scaffold): add runnable CamMonitor health service` | +| Next Role | review | + +--- + +### CYCLE-START — plan — 2026-06-18T00:00:00Z + +| Field | Value | +|-------|-------| +| Agent | claude | +| Summary | Initial planning complete for CamMonitor: 6 tasks covering scaffold, auth, footage scanner, UI shell, image browser, and video browser. All tasks moved to ready_for_implement. | +| Files Changed | ROADMAP.md, .ai/PLAN.md, .ai/TASKS.md, .ai/HANDOFF.md | +| Validation | n/a (plan role) | +| Commit | n/a (plan role) | +| Next Role | implement | + +--- diff --git a/.ai/PLAN.md b/.ai/PLAN.md new file mode 100644 index 0000000..7412993 --- /dev/null +++ b/.ai/PLAN.md @@ -0,0 +1,382 @@ +# Plan + +Status: **ready_for_implement** + +Goal: Build CamMonitor — a self-hosted security camera footage viewer. +Go backend, dark responsive UI, Docker Compose deployment, minimal CPU use on Intel Atom. + +--- + +## Constraints + +- No CGO. Use `modernc.org/sqlite` (pure Go) so the binary cross-compiles cleanly. +- No Node.js build step. Tailwind CSS loaded from CDN only. +- Minimal transcoding. Videos are remuxed (stream copy), never re-encoded. +- Thumbnails cached in memory only — no writes to the footage tree. +- Target image: `debian:bookworm-slim` + `ffmpeg` package (needed for video stream + thumbnails). + +--- + +## Scope + +| Task | What is delivered | +|------|-------------------| +| T-001 | Runnable skeleton: Go module, chi router, health endpoint, Dockerfile, docker-compose | +| T-002 | Auth: login/logout, session cookies, SQLite users + sessions, admin user-management | +| T-003 | Footage scanner: in-memory index of days/images/videos, periodic rescan | +| T-004 | UI shell: dark Tailwind layout, sidebar (desktop), mobile drawer, day overview page | +| T-005 | Image browser: raw serve, thumbnail cache, strip, full viewer, arrow nav | +| T-006 | Video browser: ffmpeg remux stream, thumbnail extraction, strip, HTML5 player, arrow nav | + +--- + +## Repository layout + +``` +CamMonitor/ +├── cmd/ +│ └── server/ +│ └── main.go # Entry point: load config, init DB, start scanner, run server +├── internal/ +│ ├── config/ +│ │ └── config.go # Read env vars into Config struct +│ ├── db/ +│ │ └── db.go # Open SQLite, run schema migrations +│ ├── auth/ +│ │ ├── store.go # User + session CRUD +│ │ ├── handler.go # /login, /logout, /admin/users handlers +│ │ └── middleware.go # RequireAuth, RequireAdmin middleware +│ ├── footage/ +│ │ ├── scanner.go # Walk FOOTAGE_ROOT, parse filenames +│ │ └── index.go # In-memory index, DayList(), DayEntry() +│ ├── image/ +│ │ ├── handler.go # /raw/image/{path}, /thumb/image/{path}, /day/{date}/images +│ │ └── thumb.go # LRU cache, JPEG resize (golang.org/x/image/draw) +│ ├── video/ +│ │ ├── handler.go # /stream/video/{path}, /thumb/video/{path}, /day/{date}/videos +│ │ ├── stream.go # ffmpeg remux: .265 → fragmented MP4 pipe +│ │ └── thumb.go # ffmpeg first-frame extract, LRU cache +│ └── web/ +│ ├── router.go # Mount all sub-routers, static files +│ └── templates/ +│ ├── base.html # Dark shell: header, sidebar, mobile nav, slot for content +│ ├── login.html # Login form +│ ├── admin_users.html # User list, add/delete form +│ ├── day.html # Day overview: tabs (Images N / Videos N) +│ ├── images.html # Thumbnail strip + full-image viewer +│ └── videos.html # Thumbnail strip + HTML5 player +├── testdata/ +│ └── footage/ # Minimal fixture tree for unit tests +│ └── 20260101/ +│ ├── images/A26010112000001.jpg +│ └── record/A260101_120000_120015.265 +├── Dockerfile +├── docker-compose.yml +├── .env.example +├── go.mod +└── README.md +``` + +--- + +## Acceptance Criteria + +- `docker compose up` starts a working app; `GET /health` returns 200. +- Unauthenticated requests redirect to `/login`. +- Admin user created on first run from `ADMIN_USER` / `ADMIN_PASS` env vars. +- `/admin/users` lets admin add and delete users. +- Footage root is populated from `FOOTAGE_ROOT` env var / volume mount. +- Images load at original quality directly from disk (no processing in the hot path). +- Selecting a video in the browser starts streaming within ~2 s. +- Thumbnail strip is visible and horizontally scrollable on both desktop and mobile. +- Arrow nav (on-screen buttons and keyboard `←`/`→`) cycles through items on the same day. +- Desktop (≥768 px): sidebar lists available days grouped by month. +- Mobile (<768 px): sidebar replaced by hamburger drawer + bottom tab bar. +- `go fmt ./...`, `go vet ./...`, `go test ./...` pass cleanly. +- `go test -race ./...` reports no data races. + +--- + +## Implementation Phases + +### Phase 1 — T-001: Project scaffold + +**New files:** +- `go.mod` — module `github.com/domagojzecevic/cammonitor`, Go 1.22 +- `cmd/server/main.go` — `main()`: call `config.Load()`, `db.Open()`, `web.NewRouter()`, `http.ListenAndServe()` +- `internal/config/config.go` — `Config` struct fields: `ListenAddr`, `FootageRoot`, `DBPath`, `AdminUser`, `AdminPass`, `SessionTTL`, `ScanInterval`; loaded from env vars with defaults +- `internal/web/router.go` — `NewRouter(cfg, db, idx)` returns `chi.Router`; mounts `/health` returning `{"status":"ok"}` +- `Dockerfile`: + ``` + FROM golang:1.22-bookworm AS builder + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN CGO_ENABLED=0 go build -o /cammonitor ./cmd/server + + FROM debian:bookworm-slim + RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/* + COPY --from=builder /cammonitor /usr/local/bin/cammonitor + ENTRYPOINT ["cammonitor"] + ``` +- `docker-compose.yml`: + ```yaml + services: + app: + build: . + ports: + - "${PORT:-8080}:8080" + environment: + FOOTAGE_ROOT: /footage + DB_PATH: /data/cammonitor.db + ADMIN_USER: ${ADMIN_USER:-admin} + ADMIN_PASS: ${ADMIN_PASS:-changeme} + SESSION_TTL: ${SESSION_TTL:-24h} + SCAN_INTERVAL: ${SCAN_INTERVAL:-5m} + volumes: + - ${FOOTAGE_ROOT:-./testdata/footage}:/footage:ro + - cammonitor_data:/data + volumes: + cammonitor_data: + ``` +- `.env.example` — documents all env vars +- `README.md` — quick-start, env var reference, footage directory format + +**Tests:** `internal/web` — GET /health returns 200 and `{"status":"ok"}`. + +--- + +### Phase 2 — T-002: Auth + +**New files:** +- `internal/db/db.go`: + - `Open(path string) (*sql.DB, error)` — opens SQLite with WAL mode, runs `migrate()` + - `migrate()` — idempotent `CREATE TABLE IF NOT EXISTS` for `users` and `sessions` + - Schema: + ```sql + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at DATETIME NOT NULL + ); + ``` + +- `internal/auth/store.go`: + - `Store` struct wrapping `*sql.DB` + - `EnsureAdmin(user, pass string)` — creates admin if no users exist (called at startup) + - `CreateUser(username, password string, isAdmin bool) error` + - `DeleteUser(id int64) error` + - `ListUsers() ([]User, error)` + - `Authenticate(username, password string) (*User, error)` — bcrypt compare + - `CreateSession(userID int64, ttl time.Duration) (token string, err error)` — crypto/rand token + - `GetSession(token string) (*Session, error)` — checks expiry + - `DeleteSession(token string) error` + - `PurgeExpiredSessions()` — called periodically from main + +- `internal/auth/middleware.go`: + - `RequireAuth(store *Store) func(http.Handler) http.Handler` — reads `session` cookie, validates, injects `*User` into context; redirects to `/login` on failure + - `RequireAdmin(next http.Handler) http.Handler` — checks `is_admin` from context; returns 403 + +- `internal/auth/handler.go`: + - `GET /login` — render login form + - `POST /login` — authenticate, set `session` cookie, redirect to `/` + - `POST /logout` — delete session, clear cookie, redirect to `/login` + - `GET /admin/users` — list users (admin only) + - `POST /admin/users` — create user (admin only) + - `POST /admin/users/{id}/delete` — delete user (admin only) + +- `internal/web/templates/login.html` — dark-themed form, username + password, submit +- `internal/web/templates/admin_users.html` — user table, add-user form, delete button per row + +**Tests:** `internal/auth` — happy-path login, wrong password, expired session, admin-only gate. + +--- + +### Phase 3 — T-003: Footage scanner + +**New files:** +- `internal/footage/scanner.go`: + - `Scan(root string) ([]DayEntry, error)` — walks root, parses directory names as `YYYYMMDD`, walks `images/` and `record/` sub-dirs + - `parseImageFilename(name string) (ImageFile, error)` — extracts timestamp from `A.jpg` + - `parseVideoFilename(name string) (VideoFile, error)` — extracts start/end time from `A__.265` + +- `internal/footage/index.go`: + - `Index` struct: `sync.RWMutex` + `map[string]*DayEntry` + - `NewIndex(root string, interval time.Duration) *Index` — starts background rescan goroutine + - `DayList() []string` — sorted date strings descending (newest first) + - `Day(date string) (*DayEntry, bool)` + - `Close()` — stop background goroutine + +- Types: + ```go + type DayEntry struct { + Date string // "20260518" + Images []ImageFile + Videos []VideoFile + } + type ImageFile struct { + RelPath string // relative to FOOTAGE_ROOT + Filename string + Timestamp time.Time + } + type VideoFile struct { + RelPath string + Filename string + StartTime time.Time + EndTime time.Time + Duration time.Duration + } + ``` + +- `testdata/footage/` — fixture directory with a couple of synthetic filenames (empty files are fine for scanner tests) + +**Tests:** `internal/footage` — scan fixture dir, assert day list, assert image/video counts and parsed timestamps; race test for concurrent `DayList()` + background rescan. + +--- + +### Phase 4 — T-004: UI shell & day navigation + +**New files:** +- `internal/web/templates/base.html`: + - Tailwind CSS CDN link (`https://cdn.tailwindcss.com`) + - Dark palette: `bg-slate-900` body, `bg-slate-800` sidebar/header, `text-slate-100` + - Header: app name "CamMonitor", logged-in username, logout button + - Desktop (≥768 px): fixed left sidebar `w-56`, main content fills remainder + - Mobile (<768 px): sidebar hidden (`hidden md:block`); hamburger button opens a full-height drawer via `` or CSS toggle; bottom tab bar (`fixed bottom-0`) with Images / Videos icons + - Go template block slots: `{{block "title" .}}`, `{{block "content" .}}` + +- `internal/web/templates/day.html`: + - Extends base; shows date heading + - Two tab buttons: "Images (N)" and "Videos (N)" — links to `/day/{date}/images` and `/day/{date}/videos` + - Active tab highlighted + +- `internal/web/handler.go` (in `web` package): + - `HandleIndex` — redirect to most recent day's images + - `HandleDayOverview` — render `day.html` with counts from index + +- **Sidebar data** — base template receives `DayList []string` from a context middleware that injects the index; days grouped by `YYYY-MM` month heading + +**Tests:** render `base.html` + `day.html` with mock data, assert key HTML elements present (sidebar date entry, tab links). + +--- + +### Phase 5 — T-005: Image browser + +**New files:** +- `internal/image/thumb.go`: + - `Cache` struct: `sync.Mutex` + `map[string][]byte` (bounded, max 500 entries, simple FIFO eviction) + - `Thumbnail(absPath string) ([]byte, error)` — decode JPEG, resize to 160×90 using `golang.org/x/image/draw.BiLinear`, encode to JPEG quality 75, store in cache + - Uses stdlib `image/jpeg` + `golang.org/x/image/draw` (no external resize library needed) + +- `internal/image/handler.go`: + - `Handler` struct: `cfg *config.Config`, `idx *footage.Index`, `thumbs *Cache` + - `ServeRaw(w, r)` — `http.ServeFile` for `/raw/image/{relpath...}`; validates path is within footage root + - `ServeThumb(w, r)` — serve from `Cache` or generate; `Cache-Control: max-age=3600` + - `ServePage(w, r)` — render `images.html`; pass image list + `idx` query param + +- `internal/web/templates/images.html`: + - Extends base + - **Thumbnail strip** (top, fixed height `h-28`): horizontal scroll, `` per image, active highlighted with indigo ring, click to navigate + - **Main viewer**: `` tag with current image URL at full container width; `object-fit: contain` + - **Arrows**: left/right `