Compare commits

...

4 Commits

  1. 185
      .ai/HANDOFF.md
  2. 206
      .ai/REVIEW.md
  3. 8
      .ai/TASKS.md
  4. 32
      README.md
  5. 25
      cmd/server/main.go
  6. 6
      go.mod
  7. 8
      go.sum
  8. 122
      internal/auth/handler.go
  9. 192
      internal/auth/handler_test.go
  10. 50
      internal/auth/middleware.go
  11. 208
      internal/auth/store.go
  12. 101
      internal/auth/store_test.go
  13. 30
      internal/db/db.go
  14. 104
      internal/footage/index.go
  15. 96
      internal/footage/index_test.go
  16. 212
      internal/footage/scanner.go
  17. 89
      internal/footage/scanner_test.go
  18. 257
      internal/image/handler.go
  19. 175
      internal/image/handler_test.go
  20. 114
      internal/image/thumb.go
  21. 137
      internal/web/handler.go
  22. 35
      internal/web/router.go
  23. 188
      internal/web/router_test.go
  24. 39
      internal/web/templates/admin_users.html
  25. 72
      internal/web/templates/base.html
  26. 16
      internal/web/templates/day.html
  27. 58
      internal/web/templates/images.html
  28. 25
      internal/web/templates/login.html
  29. 16
      internal/web/templates/templates.go
  30. 1
      testdata/footage/20260101/images/A26010112000001.jpg
  31. 1
      testdata/footage/20260101/images/A26010112050001.jpg
  32. 1
      testdata/footage/20260101/record/A260101_120000_120015.265
  33. 1
      testdata/footage/20260101/record/A260101_120500_120530.265
  34. 1
      testdata/footage/20260102/images/A26010213000001.jpg

185
.ai/HANDOFF.md

@ -2,6 +2,74 @@
Append-only role handoff log. Each role adds one entry when its step is complete. Append-only role handoff log. Each role adds one entry when its step is complete.
### T-005 — review — 2026-06-18T13:45:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Reviewed T-005 image browser; all 4 tests pass including thumbnail size, raw byte-equality, path traversal rejection, and page nav assertions; three nit/minor findings, none blocking. |
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, `go test ./internal/image/... -v`, `go test -race ./...` all pass. |
| Verdict | PASS_WITH_NOTES |
| Blocking Findings | none |
| Next Role | implement |
---
### T-004 — review — 2026-06-18T13:25:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Reviewed T-004 UI shell; all 5 web tests pass including responsive class assertions, month grouping, and tab counts; four nit-level findings only — no blockers. |
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, `go test ./internal/web/... -v`, `go test -race ./...` all pass. |
| Verdict | PASS_WITH_NOTES |
| Blocking Findings | none |
| Next Role | implement |
---
### T-003 — review — 2026-06-18T11:05:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Reviewed T-003 footage scanner; all 7 tests pass including race detector; two nit-level findings only — no blockers. |
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, `go test ./internal/footage/... -v`, `go test -race ./...` all pass. |
| Verdict | PASS_WITH_NOTES |
| Blocking Findings | none |
| Next Role | implement |
---
### T-002 — review — 2026-06-18T10:50:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Rework pass review for T-002: blocking finding resolved — templates are now file-backed via embed.FS in `internal/web/templates/`; all tests pass; no new findings. |
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...` all pass. |
| Verdict | PASS |
| Blocking Findings | none |
| Next Role | implement |
---
### T-002 — review — 2026-06-18T10:35:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Reviewed T-002 auth; all tests pass but templates are inline Go strings instead of the file-based HTML required by the plan — blocking T-004 base template integration. |
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...` all pass; all 9 auth+web tests PASS. |
| Verdict | FAIL |
| Blocking Findings | 1. Templates are inline Go strings in handler.go instead of separate files in `internal/web/templates/` — must be moved to file-based templates before T-004 base template inheritance is possible. |
| Next Role | implement |
--- ---
### T-001 — implement — 2026-06-18T08:20:22Z ### T-001 — implement — 2026-06-18T08:20:22Z
@ -17,6 +85,32 @@ Append-only role handoff log. Each role adds one entry when its step is complete
--- ---
### T-003 — implement — 2026-06-18T09:04:41Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Added the footage scanner, in-memory day index, startup wiring, fixture coverage, and README scanner documentation. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md, README.md, cmd/server/main.go, internal/web/router.go, internal/footage/scanner.go, internal/footage/index.go, internal/footage/scanner_test.go, internal/footage/index_test.go, testdata/footage/20260101/images/A26010112000001.jpg, testdata/footage/20260101/images/A26010112050001.jpg, testdata/footage/20260101/record/A260101_120000_120015.265, testdata/footage/20260101/record/A260101_120500_120530.265, testdata/footage/20260102/images/A26010213000001.jpg |
| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./internal/footage/...` passed; `go test ./...` passed; `go test -race ./...` passed |
| Commit | `feat(footage): index camera footage by day` |
| Next Role | review |
---
### T-003 — implement — 2026-06-18T13:03:48Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Committed T-003 footage scanner after review approval. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | n/a (commit_task) |
| Commit | `pending feat(footage): index camera footage by day` |
| Next Role | none |
---
### T-001 — review — 2026-06-18T10:20:00Z ### T-001 — review — 2026-06-18T10:20:00Z
| Field | Value | | Field | Value |
@ -56,3 +150,94 @@ Append-only role handoff log. Each role adds one entry when its step is complete
| Next Role | implement | | Next Role | implement |
--- ---
### T-002 — implement — 2026-06-18T08:31:56Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Added SQLite-backed authentication with bcrypt login/logout, session-cookie middleware, first-run admin bootstrap, and admin user management. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md, README.md, cmd/server/main.go, go.mod, go.sum, internal/auth/handler.go, internal/auth/handler_test.go, internal/auth/middleware.go, internal/auth/store.go, internal/auth/store_test.go, internal/db/db.go, internal/web/router.go, internal/web/router_test.go, internal/web/templates/admin_users.html, internal/web/templates/login.html |
| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./...` passed; `go test -race ./...` passed; `go test ./internal/auth/... ./internal/web/...` passed |
| Commit | `feat(auth): add login and admin user management` |
| Next Role | review |
---
### T-002 — implement — 2026-06-18T08:42:00Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Addressed review finding by moving auth page rendering to file-backed templates exposed through an embedded template registry. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md, internal/auth/handler.go, internal/auth/handler_test.go, internal/web/templates/admin_users.html, internal/web/templates/login.html, internal/web/templates/templates.go |
| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./...` passed; `go test -race ./...` passed; `go test ./internal/auth/...` passed |
| Commit | `feat(auth): add login and admin user management` |
| Next Role | review |
---
### T-002 — implement — 2026-06-18T08:52:43Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Committed T-002 auth after review approval. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | n/a (commit_task) |
| Commit | `pending feat(auth): add login and admin user management` |
| Next Role | none |
---
### T-004 — implement — 2026-06-18T13:10:37Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Added the responsive dark UI shell, month-grouped day navigation, mobile drawer and bottom tabs, and day overview counts. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md, README.md, internal/web/handler.go, internal/web/router.go, internal/web/router_test.go, internal/web/templates/base.html, internal/web/templates/day.html, internal/web/templates/templates.go |
| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./internal/web/...` passed; `go test ./...` passed; `go test -race ./...` passed |
| Commit | `feat(ui): add responsive day navigation shell` |
| Next Role | review |
---
### T-004 — implement — 2026-06-18T13:19:26Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Committed T-004 responsive UI shell after review approval. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | n/a (commit_task) |
| Commit | `pending feat(ui): add responsive day navigation shell` |
| Next Role | none |
---
### T-005 — implement — 2026-06-18T13:31:09Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Added authenticated raw image serving, cached 160x90 JPEG thumbnails, and a deep-linkable image browser with thumbnail strip and keyboard/on-screen navigation. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md, README.md, go.mod, go.sum, internal/image/handler.go, internal/image/handler_test.go, internal/image/thumb.go, internal/web/router.go, internal/web/router_test.go, internal/web/templates/images.html, internal/web/templates/templates.go |
| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./internal/image/...` passed; `go test ./...` passed; `go test -race ./...` passed |
| Commit | `feat(image): add image browser and thumbnails` |
| Next Role | review |
---
### T-005 — implement — 2026-06-18T13:39:09Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Committed T-005 image browser after review approval. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | n/a (commit_task) |
| Commit | `pending feat(image): add image browser and thumbnails` |
| Next Role | none |
---

206
.ai/REVIEW.md

@ -21,6 +21,52 @@ Append-only reviewer log. Each reviewed task gets one section.
None — all findings are nits. No blocking issues. None — all findings are nits. No blocking issues.
---
## T-002 — Auth
**Verdict:** FAIL
### Findings
| # | Severity | File | Description | Required fix? |
|---|----------|------|-------------|---------------|
| 1 | major | `internal/auth/handler.go` (lines 119–183) | Templates (`login`, `admin_users`) are inline Go template strings, not separate `.html` files. The plan's repository layout explicitly lists `internal/web/templates/login.html` and `internal/web/templates/admin_users.html`. T-004 introduces `base.html` with `{{block "title" .}}` / `{{block "content" .}}` template inheritance — the admin_users page must be able to extend that base. Inline strings in a different package make that impossible without a refactor spike inside T-004. | **yes** |
| 2 | minor | `internal/auth/handler.go` (lines 49–56) | Session cookie has no `Secure` flag. If the app is reverse-proxied over HTTPS (the expected production path), the cookie is transmitted over HTTP between the browser and the proxy for nothing, defeating `HttpOnly`. Acceptable for pure LAN-only deployment but should be documented or made configurable. | no |
| 3 | minor | `internal/auth/handler.go` (lines 104–116) | `DeleteUser` does not prevent deleting the last admin (or the currently logged-in user). An admin could inadvertently lock out all access. | no |
| 4 | nit | `go.mod` (line 3) | Go version bumped from `1.22` (specified in the plan) to `1.25.0`. Higher is generally fine but is an undocumented deviation from the plan's stated constraint. | no |
| 5 | nit | `cmd/server/main.go` (lines 40–49) | `purgeExpiredSessions` goroutine has no stop channel and cannot be shut down cleanly. Harmless in practice (OS reclaims on exit) but leaves dangling goroutine state. | no |
### Required fixes
**Finding #1 — move templates to separate files**
Move `loginTemplate` and `adminUsersTemplate` out of `handler.go` and into:
- `internal/web/templates/login.html`
- `internal/web/templates/admin_users.html`
Load them via `embed.FS` or `template.ParseFiles` in a shared template registry (or individually per handler). Structure the templates so T-004 can add `{{block "content" .}}` / `{{block "title" .}}` inheritance without touching auth code. The login page may stay standalone (pre-auth, no sidebar). `admin_users.html` at minimum must be a separate file so T-004 can wrap it in the base shell.
### Verification
**Steps performed:**
1. Read all new/changed files: `internal/auth/handler.go`, `internal/auth/middleware.go`, `internal/auth/store.go`, `internal/auth/handler_test.go`, `internal/auth/store_test.go`, `internal/db/db.go`, `internal/web/router.go`, `internal/web/router_test.go`, `cmd/server/main.go`, `go.mod`.
2. Cross-checked implementation against `.ai/PLAN.md` Phase 2 scope and repository layout.
3. Ran `go fmt ./...` — clean.
4. Ran `go vet ./...` — clean.
5. Ran `go test ./...``internal/auth` PASS, `internal/web` PASS.
6. Ran `go test -race ./...` — PASS, no data races.
7. Ran `go test ./internal/auth/... ./internal/web/... -v` — all 9 tests pass (login renders, valid/invalid credentials, admin gate, store auth, expired session, ensure-admin idempotency, health, unauthenticated redirect).
**Findings:**
- All acceptance criteria met at the functional level: login renders, valid credentials redirect + set cookie, invalid returns 401, admin gate enforced, unauthenticated routes redirect.
- SQLite schema matches plan exactly (users + sessions tables, WAL mode, foreign keys).
- `EnsureAdmin` idempotency tested and correct. bcrypt + crypto/rand token generation correct. Cookie expiry delete pattern (`MaxAge: -1`) is correct. Token is 32-byte base64url — adequate entropy.
- Main structural deviation is inline templates (finding #1), which blocks clean T-004 integration.
**Risks:**
- If finding #1 is not fixed, T-004 will need a refactor spike to migrate inline templates to file-based ones before base template inheritance can work, increasing T-004 scope unexpectedly.
### Verification ### Verification
**Steps performed:** **Steps performed:**
@ -45,3 +91,163 @@ None — all findings are nits. No blocking issues.
- `.env.example` gaps (finding #1) are low-risk for a scaffold task but should be cleaned up before shipping. - `.env.example` gaps (finding #1) are low-risk for a scaffold task but should be cleaned up before shipping.
--- ---
## T-002 — Auth (rework pass)
**Verdict:** PASS
### Findings
All findings from the initial review pass addressed or confirmed non-blocking. No new findings.
### Required fixes
None.
### Verification
**Steps performed:**
1. Read reworked files: `internal/auth/handler.go`, `internal/auth/handler_test.go`, `internal/web/templates/templates.go`, `internal/web/templates/login.html`, `internal/web/templates/admin_users.html`.
2. Confirmed blocking finding #1 resolved: templates moved to `internal/web/templates/*.html`; `templates.go` exposes `embed.FS` with `//go:embed *.html` and named string constants; `handler.go` loads via `template.ParseFS(webtemplates.FS, ...)` and renders via `ExecuteTemplate`.
3. Tests extended with `readTemplateFile` / `renderTemplateFile` helpers that read from disk, proving file-backed rendering is live (not a cached inline string).
4. Ran `go fmt ./...` — clean.
5. Ran `go vet ./...` — clean.
6. Ran `go test ./...` — all packages PASS.
7. Ran `go test -race ./...` — PASS, no data races.
**Findings:**
- `templates.go` `embed.FS` gives T-004 a clean extension point — it can register `base.html` in the same package and `admin_users.html` can reference it when T-004 lands.
- `template.ParseFS` + `ExecuteTemplate(w, "login.html", nil)` pattern is correct; template name matches the file name.
- Non-blocking nits from initial review (no `Secure` cookie flag, no last-admin guard, go 1.25.0 version bump, non-stoppable purge goroutine) remain; none block this task.
**Risks:**
- When T-004 adds `base.html` to the embed glob, template parse order matters; the implementer should parse base first or use `template.ParseFS(..., "base.html", "admin_users.html")` to ensure the base is available before extension templates.
---
## T-003 — Footage scanner
**Verdict:** PASS_WITH_NOTES
### Findings
| # | Severity | File | Description | Required fix? |
|---|----------|------|-------------|---------------|
| 1 | nit | `internal/footage/index.go` (line 83) | `rescan()` silently discards `Scan()` errors — the index silently retains stale data with no log output. Hard to diagnose filesystem issues in production. | no |
| 2 | nit | `internal/web/router.go` (line 14) | `_ *footage.Index` parameter accepted but still ignored. Correct for T-003 scope; will be wired in T-004. | no |
### Required fixes
None.
### Verification
**Steps performed:**
1. Read all new/changed files: `internal/footage/scanner.go`, `internal/footage/index.go`, `internal/footage/scanner_test.go`, `internal/footage/index_test.go`, `cmd/server/main.go`, `internal/web/router.go`.
2. Cross-checked against `.ai/PLAN.md` Phase 3 scope — all required types, functions, and behaviours implemented.
3. Inspected fixture files in `testdata/footage/` — 5 files across 2 day directories, correct naming pattern, 8 bytes each.
4. Checked regex patterns against fixture filenames: image `^A(\d{6})(\d{6})\d+\.jpg$` and video `^A(\d{6})_(\d{6})_(\d{6})\.265$` both match.
5. Verified time parsing format `"060102150405"` (YYMMDD + HHMMSS) produces correct timestamps for fixture filenames.
6. Ran `go fmt ./...` — clean.
7. Ran `go vet ./...` — clean.
8. Ran `go test ./internal/footage/... -v` — all 7 tests PASS.
9. Ran `go test -race ./...` — PASS, no data races (cached + fresh run).
**Findings:**
- All acceptance criteria met: fixture directory indexed correctly (2 days, correct image/video counts), `DayList()` returns newest-first sorted dates, `DayEntry` contains timestamp-sorted images and start-time-sorted videos, periodic rescan confirmed by `TestIndexPeriodicRescan`, race detector clean on `TestIndexConcurrentAccessDuringRescan` (8 goroutines × 100 iterations).
- `copyDayEntry` defensively copies slices on every `Day()` call — callers cannot mutate internal index state. ✅
- `Close()` uses `sync.Once` around `close(done)` — safe to call multiple times. ✅
- `NewIndex` does an initial synchronous `rescan()` before starting the goroutine — index is always non-empty on first use if footage exists. ✅
- Midnight-spanning clips handled: `if end.Before(start) { end = end.Add(24 * time.Hour) }`. ✅
- Missing `images/` or `record/` subdirectory returns `nil, nil` (not an error) — scanner gracefully handles partial day directories. ✅
- `main.go` wires `footage.NewIndex` and defers `Close()` correctly. ✅
**Risks:**
- Silent error discard in `rescan()` (finding #1) is low risk for correctness but could delay diagnosis of a footage mount problem in production.
---
## T-004 — UI shell & day navigation
**Verdict:** PASS_WITH_NOTES
### Findings
| # | Severity | File | Description | Required fix? |
|---|----------|------|-------------|---------------|
| 1 | minor | `internal/web/handler.go` (lines 81–84) | `render()` sets `Content-Type` header then calls `ExecuteTemplate` directly to `w`. If template execution fails after writing any bytes, the subsequent `http.Error(w, ...)` call is a no-op — headers are already sent and the HTTP status code cannot be changed. Fix: render into a `bytes.Buffer` first, check error, then write in one shot. | no |
| 2 | nit | `internal/web/handler.go` (line 48–51) | Empty-footage case sets `Date: "No footage"` in `DayPageData` and renders the day template, which shows `"No footage"` as the date heading. Minor UX rough edge; a dedicated empty-state template or prose would be cleaner. | no |
| 3 | nit | `internal/web/handler.go` (line 24) | `ShellData.ActiveTab` field is declared but never written or read in current templates. Scaffolded for T-005/T-006. Harmless. | no |
| 4 | nit | `internal/web/templates/admin_users.html` | Admin users page still does not extend `base.html` — it has no sidebar or mobile nav. Acceptable for current scope; T-004 plan does not require refactoring auth pages. | no |
### Required fixes
None.
### Verification
**Steps performed:**
1. Read all new/changed files: `internal/web/handler.go`, `internal/web/router.go`, `internal/web/router_test.go`, `internal/web/templates/base.html`, `internal/web/templates/day.html`, `internal/web/templates/templates.go`.
2. Cross-checked against `.ai/PLAN.md` Phase 4 scope.
3. Ran `go fmt ./...` — clean.
4. Ran `go vet ./...` — clean.
5. Ran `go test ./internal/web/... -v` — all 5 tests PASS.
6. Ran `go test -race ./...` — PASS, no data races.
**Findings:**
- All acceptance criteria met: `hidden ... md:block` on `<aside>` hides sidebar at <768 px and shows at 768 px; `data-mobile-drawer` `<details>/<summary>` provides hamburger drawer on mobile; bottom `<nav>` with `md:hidden` is the mobile tab bar; `TestDayOverviewRendersShellNavigationAndCounts` asserts both responsive class names, month grouping, sidebar day links, image/video counts, and tab links.
- `{{define "base"}}` / `{{template "content" .}}` pattern is a sound Go template composition approach — functionally equivalent to `{{block "content" .}}`.
- `groupDaysByMonth` correctly preserves newest-first order within each month group (days come from `DayList()` which is already sorted).
- Mobile hamburger uses pure `<details>`/`<summary>` — no JavaScript required for open/close. ✅
- `render()` pulls `User` from context and `Months` from the live index on every request — sidebar always fresh. ✅
- `Index` handler nil-safe (`h.index == nil` guard). ✅
- `Day()` returns `http.NotFound` for unknown dates. ✅
- Tests cover: redirect to newest day on `/`, day overview with all shell assertions, 404 for missing day, and existing auth/health routes still pass.
**Risks:**
- Finding #1 (buffer-less render) will become more visible when T-005/T-006 handlers reuse the same `render()` pattern with more dynamic data; recommend fixing before T-006 lands.
---
## T-005 — Image browser
**Verdict:** PASS_WITH_NOTES
### Findings
| # | Severity | File | Description | Required fix? |
|---|----------|------|-------------|---------------|
| 1 | minor | `internal/image/handler.go` (lines 27–40, 221–251) | `ShellData`, `MonthGroup`, and `groupDaysByMonth` are exact duplicates of the same identifiers already in `internal/web/handler.go`. `internal/image` cannot import `internal/web` (circular: `internal/web``internal/image`), so the fix requires extracting these shared shell types to a new package (e.g. `internal/shell` or `internal/ui`). T-006 will add a third copy of the same code if this is not resolved. | no |
| 2 | nit | `internal/image/handler.go` (lines 182–185) | Same buffer-less render pattern as T-004: `Content-Type` header written before `ExecuteTemplate`, making the `http.Error` fallback a no-op if the template fails mid-write. | no |
| 3 | nit | `internal/web/templates/images.html` (lines 41–45) | Arrow navigation uses `window.location.href = url.toString()` (full page reload) rather than `history.pushState` with a partial update as described in the plan design notes. The acceptance criteria don't require push-state; navigation is functionally correct. | no |
### Required fixes
None.
### Verification
**Steps performed:**
1. Read all new files: `internal/image/thumb.go`, `internal/image/handler.go`, `internal/image/handler_test.go`, `internal/web/templates/images.html`, `internal/web/templates/templates.go`, `internal/web/router.go`, `go.mod`.
2. Cross-checked against `.ai/PLAN.md` Phase 5 scope.
3. Confirmed `golang.org/x/image v0.42.0` added to `go.mod`. ✅
4. Ran `go fmt ./...` — clean.
5. Ran `go vet ./...` — clean.
6. Ran `go test ./internal/image/... -v` — all 4 tests PASS.
7. Ran `go test -race ./...` — PASS, no data races.
**Findings:**
- All acceptance criteria met: thumbnail JPEG ≤30 KB verified by test decoding the response; raw endpoint byte-equality verified; path traversal (`%2e%2e`) returns 400; page renders strip (ring highlight), viewer, prev/next links, keyboard arrows.
- FIFO eviction (`order []string` + slice shift) is correct and bounded to `max` entries. ✅
- `resizeToFit` uses aspect-ratio-preserving scaling: scales to fit within 160×90, never stretches. ✅
- `Cache.get` and `Cache.add` return defensive copies — callers cannot alias internal byte slices. ✅
- Path validation uses both a string prefix check (`.."`) and `filepath.Rel` escape check — two independent layers. ✅
- `Cache-Control: max-age=3600` set on thumb responses. ✅
- Routes wired correctly under `RequireAuth` middleware in `router.go`. ✅
- `imageRouter` test helper bypasses auth (tests the handler directly), which is correct for unit-level coverage. ✅
- Wrapping arithmetic for prev/next is correct: `(active ± 1 + len) % len`. Test validates idx=1 with 2 images gives prev=0, next=0. ✅
**Risks:**
- Finding #1 (shell type duplication) is the main structural risk: T-006 will face the same choice and may add a third copy. The T-006 implementer should extract shared types before or during that task.
---

8
.ai/TASKS.md

@ -21,8 +21,8 @@ Command expectations:
| Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role | | Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| T-001 | Project scaffold: Go module, chi router, health endpoint, multi-stage Dockerfile (debian:slim + ffmpeg), docker-compose.yml, .env.example, README.md | done | `docker compose build` succeeds; `GET /health` returns 200; `go vet ./...` passes | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...`, `docker compose build`, `curl -i http://127.0.0.1:18080/health` passed | none | | T-001 | Project scaffold: Go module, chi router, health endpoint, multi-stage Dockerfile (debian:slim + ffmpeg), docker-compose.yml, .env.example, README.md | done | `docker compose build` succeeds; `GET /health` returns 200; `go vet ./...` passes | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...`, `docker compose build`, `curl -i http://127.0.0.1:18080/health` passed | none |
| T-002 | Auth: SQLite schema (users + sessions), bcrypt login/logout, session cookie middleware, first-run admin bootstrap, admin user-management page | ready_for_implement | Login page renders at `/login`; valid credentials set session cookie and redirect; invalid credentials return 401; admin can add/delete users at `/admin/users`; all non-login routes return 302 without cookie; `go test ./internal/auth/...` passes | n/a | implement | | T-002 | Auth: SQLite schema (users + sessions), bcrypt login/logout, session cookie middleware, first-run admin bootstrap, admin user-management page | done | Login page renders at `/login`; valid credentials set session cookie and redirect; invalid credentials return 401; admin can add/delete users at `/admin/users`; all non-login routes return 302 without cookie; `go test ./internal/auth/...` passes | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...`, `go test ./internal/auth/...` passed after template rework | none |
| T-003 | Footage scanner: walk FOOTAGE_ROOT, build in-memory index (date → images + videos), filename parser, periodic rescan goroutine | ready_for_implement | Scanner correctly indexes fixture directory with known dates/files; `DayList()` returns sorted dates; `DayEntry` contains sorted images and videos; periodic rescan runs without data race (`go test -race ./internal/footage/...` passes) | n/a | implement | | T-003 | Footage scanner: walk FOOTAGE_ROOT, build in-memory index (date → images + videos), filename parser, periodic rescan goroutine | done | Scanner correctly indexes fixture directory with known dates/files; `DayList()` returns sorted dates; `DayEntry` contains sorted images and videos; periodic rescan runs without data race (`go test -race ./internal/footage/...` passes) | `go fmt ./...`, `go vet ./...`, `go test ./internal/footage/...`, `go test ./...`, `go test -race ./...` passed | none |
| T-004 | UI shell & day navigation: dark Tailwind CSS base layout, sidebar (desktop) with date list grouped by month, mobile drawer + bottom tab bar, day overview page with Images/Videos tabs | ready_for_implement | Desktop layout shows sidebar at ≥768 px; mobile layout hides sidebar and shows drawer trigger at <768 px; day overview page renders images and videos tab counts; `go vet ./...` passes | n/a | implement | | T-004 | UI shell & day navigation: dark Tailwind CSS base layout, sidebar (desktop) with date list grouped by month, mobile drawer + bottom tab bar, day overview page with Images/Videos tabs | done | Desktop layout shows sidebar at ≥768 px; mobile layout hides sidebar and shows drawer trigger at <768 px; day overview page renders images and videos tab counts; `go vet ./...` passes | `go fmt ./...`, `go vet ./...`, `go test ./internal/web/...`, `go test ./...`, `go test -race ./...` passed | none |
| T-005 | Image browser: raw JPEG serving, in-memory LRU thumbnail cache (160×90), thumbnail strip, full-image viewer, arrow navigation (keyboard + on-screen), deep-linkable `?idx=N` | ready_for_implement | Thumbnail endpoint returns JPEG ≤30 KB for a test image; raw endpoint serves original file unchanged; arrow nav advances and wraps correctly; keyboard left/right works; strip highlights active item; `go test ./internal/image/...` passes | n/a | implement | | T-005 | Image browser: raw JPEG serving, in-memory LRU thumbnail cache (160×90), thumbnail strip, full-image viewer, arrow navigation (keyboard + on-screen), deep-linkable `?idx=N` | done | Thumbnail endpoint returns JPEG ≤30 KB for a test image; raw endpoint serves original file unchanged; arrow nav advances and wraps correctly; keyboard left/right works; strip highlights active item; `go test ./internal/image/...` passes | `go fmt ./...`, `go vet ./...`, `go test ./internal/image/...`, `go test ./...`, `go test -race ./...` passed | none |
| T-006 | Video browser: on-demand ffmpeg remux stream (.265→MP4 via pipe), in-memory LRU video thumbnail (first-frame extract), thumbnail strip, HTML5 player, arrow navigation, deep-linkable `?idx=N` | ready_for_implement | Stream endpoint returns `Content-Type: video/mp4` and non-empty body for a test .265 file; thumbnail endpoint returns a JPEG; player page renders with `<video>` element; arrow nav works; ffmpeg process is killed on client disconnect; `go test ./internal/video/...` passes | n/a | implement | | T-006 | Video browser: on-demand ffmpeg remux stream (.265→MP4 via pipe), in-memory LRU video thumbnail (first-frame extract), thumbnail strip, HTML5 player, arrow navigation, deep-linkable `?idx=N` | ready_for_implement | Stream endpoint returns `Content-Type: video/mp4` and non-empty body for a test .265 file; thumbnail endpoint returns a JPEG; player page renders with `<video>` element; arrow nav works; ffmpeg process is killed on client disconnect; `go test ./internal/video/...` passes | n/a | implement |

32
README.md

@ -2,7 +2,7 @@
## Overview ## Overview
CamMonitor is a self-hosted security camera footage viewer. This scaffold provides the Go HTTP server, a `/health` endpoint, environment-based configuration, SQLite startup, and Docker Compose deployment. CamMonitor is a self-hosted security camera footage viewer. It provides the Go HTTP server, a `/health` endpoint, environment-based configuration, SQLite startup and migrations, bcrypt login, session cookies, admin user management, an in-memory footage index, and Docker Compose deployment.
## Getting Started ## Getting Started
@ -30,6 +30,8 @@ The response should be:
{"status":"ok"} {"status":"ok"}
``` ```
Open `http://localhost:8080/login` and sign in with `ADMIN_USER` and `ADMIN_PASS`. On the first run, CamMonitor creates that bootstrap admin account only when the user table is empty. After login, admins can add or delete users at `/admin/users`.
## Configuration ## Configuration
| Variable | Default | Description | | Variable | Default | Description |
@ -38,14 +40,34 @@ The response should be:
| `LISTEN_ADDR` | `:8080` | TCP address used by the Go server | | `LISTEN_ADDR` | `:8080` | TCP address used by the Go server |
| `FOOTAGE_ROOT` | `/footage` | Camera footage directory inside the container | | `FOOTAGE_ROOT` | `/footage` | Camera footage directory inside the container |
| `DB_PATH` | `/data/cammonitor.db` | SQLite database path | | `DB_PATH` | `/data/cammonitor.db` | SQLite database path |
| `ADMIN_USER` | `admin` | Bootstrap admin username for the auth phase | | `ADMIN_USER` | `admin` | Bootstrap admin username created on first run when no users exist |
| `ADMIN_PASS` | `changeme` | Bootstrap admin password for the auth phase | | `ADMIN_PASS` | `changeme` | Bootstrap admin password created on first run when no users exist |
| `SESSION_TTL` | `24h` | Session lifetime | | `SESSION_TTL` | `24h` | Session cookie lifetime |
| `SCAN_INTERVAL` | `5m` | Footage rescan interval | | `SCAN_INTERVAL` | `5m` | Footage rescan interval |
## Authentication
All app routes except `/health`, `/login`, and `POST /login` require a valid `session` cookie. Unauthenticated requests are redirected to `/login`. Admin-only pages return `403 Forbidden` for signed-in non-admin users.
The login form posts credentials to `/login`. Successful login sets an HTTP-only `session` cookie and redirects to `/`; invalid credentials return `401 Unauthorized`. `POST /logout` deletes the server-side session, clears the cookie, and redirects back to `/login`.
Admin users can manage accounts at `/admin/users`. New users are created with bcrypt-hashed passwords, and deleting a user also removes their sessions through the SQLite foreign key cascade.
## Browsing Footage
After login, `/` redirects to the newest indexed day's image browser at `/day/YYYYMMDD/images`. The day overview at `/day/YYYYMMDD` shows the available image and video counts with links to `/day/YYYYMMDD/images` and `/day/YYYYMMDD/videos`.
The image browser at `/day/YYYYMMDD/images` shows a horizontal thumbnail strip and a full-size viewer for the selected image. Use the on-screen previous and next buttons or the left and right arrow keys to move through images for the day; navigation wraps at the ends. The current image is deep-linkable with `?idx=N`, where `N` is the zero-based image index.
Original JPEGs are served from `/raw/image/<relative-path>` after validating that the path stays inside `FOOTAGE_ROOT`. Thumbnails are served from `/thumb/image/<relative-path>` as cached 160x90 JPEGs generated in memory only.
On desktop widths, the app shows a dark sidebar with indexed days grouped by month. On smaller screens, the sidebar is replaced by a drawer trigger in the header and a fixed bottom tab bar for switching between Images and Videos.
## Footage Layout ## Footage Layout
Footage is mounted read-only at `/footage` in the container. The planned directory format is: Footage is mounted read-only at `/footage` in the container. CamMonitor scans this tree on startup and then rescans it on the `SCAN_INTERVAL` schedule. Days are indexed from `YYYYMMDD` directories, with images and videos sorted chronologically inside each day.
The expected directory format is:
```text ```text
YYYYMMDD/ YYYYMMDD/

25
cmd/server/main.go

@ -3,9 +3,12 @@ package main
import ( import (
"log" "log"
"net/http" "net/http"
"time"
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/config" "github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/db" "github.com/domagojzecevic/cammonitor/internal/db"
"github.com/domagojzecevic/cammonitor/internal/footage"
"github.com/domagojzecevic/cammonitor/internal/web" "github.com/domagojzecevic/cammonitor/internal/web"
) )
@ -21,10 +24,30 @@ func main() {
} }
defer database.Close() defer database.Close()
router := web.NewRouter(cfg, database, nil) authStore := auth.NewStore(database)
if err := authStore.EnsureAdmin(cfg.AdminUser, cfg.AdminPass); err != nil {
log.Fatalf("ensure admin: %v", err)
}
go purgeExpiredSessions(authStore)
footageIndex := footage.NewIndex(cfg.FootageRoot, cfg.ScanInterval)
defer footageIndex.Close()
router := web.NewRouter(cfg, database, footageIndex)
log.Printf("listening on %s", cfg.ListenAddr) log.Printf("listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, router); err != nil { if err := http.ListenAndServe(cfg.ListenAddr, router); err != nil {
log.Fatalf("server stopped: %v", err) log.Fatalf("server stopped: %v", err)
} }
} }
func purgeExpiredSessions(store *auth.Store) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
if err := store.PurgeExpiredSessions(); err != nil {
log.Printf("purge expired sessions: %v", err)
}
}
}

6
go.mod

@ -1,9 +1,11 @@
module github.com/domagojzecevic/cammonitor module github.com/domagojzecevic/cammonitor
go 1.22 go 1.25.0
require ( require (
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
golang.org/x/crypto v0.31.0
golang.org/x/image v0.42.0
modernc.org/sqlite v1.34.5 modernc.org/sqlite v1.34.5
) )
@ -13,7 +15,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.22.0 // indirect golang.org/x/sys v0.46.0 // indirect
modernc.org/libc v1.55.3 // indirect modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect modernc.org/memory v1.8.0 // indirect

8
go.sum

@ -12,11 +12,15 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=

122
internal/auth/handler.go

@ -0,0 +1,122 @@
package auth
import (
"errors"
"html/template"
"net/http"
"strconv"
"time"
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates"
"github.com/go-chi/chi/v5"
)
type Handler struct {
store *Store
sessionTTL time.Duration
}
func NewHandler(store *Store, sessionTTL time.Duration) *Handler {
return &Handler{store: store, sessionTTL: sessionTTL}
}
func (h *Handler) LoginPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = loginTemplate.ExecuteTemplate(w, webtemplates.Login, nil)
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
user, err := h.store.Authenticate(r.FormValue("username"), r.FormValue("password"))
if errors.Is(err, ErrInvalidCredentials) {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
if err != nil {
http.Error(w, "login failed", http.StatusInternalServerError)
return
}
token, err := h.store.CreateSession(user.ID, h.sessionTTL)
if err != nil {
http.Error(w, "create session failed", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
Expires: time.Now().UTC().Add(h.sessionTTL),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil {
_ = h.store.DeleteSession(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (h *Handler) UsersPage(w http.ResponseWriter, _ *http.Request) {
users, err := h.store.ListUsers()
if err != nil {
http.Error(w, "list users failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = adminUsersTemplate.ExecuteTemplate(w, webtemplates.AdminUsers, struct {
Users []User
}{Users: users})
}
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
if err := h.store.CreateUser(r.FormValue("username"), r.FormValue("password"), r.FormValue("is_admin") == "on"); err != nil {
http.Error(w, "create user failed", http.StatusBadRequest)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "invalid user id", http.StatusBadRequest)
return
}
if err := h.store.DeleteUser(id); err != nil {
http.Error(w, "delete user failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
var loginTemplate = template.Must(template.ParseFS(webtemplates.FS, webtemplates.Login))
var adminUsersTemplate = template.Must(template.ParseFS(webtemplates.FS, webtemplates.AdminUsers))

192
internal/auth/handler_test.go

@ -0,0 +1,192 @@
package auth
import (
"bytes"
"html/template"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestLoginPageRenders(t *testing.T) {
handler, _, _ := newTestHandler(t)
request := httptest.NewRequest(http.MethodGet, "/login", nil)
response := httptest.NewRecorder()
handler.LoginPage(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", response.Code)
}
if body := response.Body.String(); !strings.Contains(body, `<form`) || !strings.Contains(body, `name="username"`) {
t.Fatalf("login page missing expected form fields: %s", body)
}
if got, want := strings.TrimSpace(response.Body.String()), strings.TrimSpace(readTemplateFile(t, "login.html")); got != want {
t.Fatalf("login page was not rendered from template file\nwant:\n%s\n\ngot:\n%s", want, got)
}
}
func TestLoginValidCredentialsSetSessionCookieAndRedirect(t *testing.T) {
handler, store, _ := newTestHandler(t)
if err := store.CreateUser("alice", "secret", false); err != nil {
t.Fatalf("create user: %v", err)
}
form := url.Values{"username": {"alice"}, "password": {"secret"}}
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
handler.Login(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected 303, got %d", response.Code)
}
if location := response.Header().Get("Location"); location != "/" {
t.Fatalf("expected redirect to /, got %q", location)
}
cookie := findCookie(response.Result().Cookies(), sessionCookieName)
if cookie == nil || cookie.Value == "" {
t.Fatalf("expected session cookie, got %#v", response.Result().Cookies())
}
if _, err := store.GetSession(cookie.Value); err != nil {
t.Fatalf("session cookie was not persisted: %v", err)
}
}
func TestLoginInvalidCredentialsReturnsUnauthorized(t *testing.T) {
handler, store, _ := newTestHandler(t)
if err := store.CreateUser("alice", "secret", false); err != nil {
t.Fatalf("create user: %v", err)
}
form := url.Values{"username": {"alice"}, "password": {"wrong"}}
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
handler.Login(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", response.Code)
}
}
func TestAdminUsersRequiresAdmin(t *testing.T) {
handler, store, mux := newTestHandler(t)
if err := store.CreateUser("alice", "secret", false); err != nil {
t.Fatalf("create regular user: %v", err)
}
if err := store.CreateUser("admin", "secret", true); err != nil {
t.Fatalf("create admin user: %v", err)
}
mux.Handle("/admin/users", RequireAuth(store)(RequireAdmin(http.HandlerFunc(handler.UsersPage))))
request := httptest.NewRequest(http.MethodGet, "/admin/users", nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusFound {
t.Fatalf("expected unauthenticated redirect, got %d", response.Code)
}
if location := response.Header().Get("Location"); location != "/login" {
t.Fatalf("expected redirect to /login, got %q", location)
}
regularCookie := sessionCookie(t, store, "alice", "secret")
request = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
request.AddCookie(regularCookie)
response = httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("expected regular user forbidden, got %d", response.Code)
}
adminCookie := sessionCookie(t, store, "admin", "secret")
request = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
request.AddCookie(adminCookie)
response = httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected admin page, got %d", response.Code)
}
if !strings.Contains(response.Body.String(), "admin") {
t.Fatalf("admin page missing user list: %s", response.Body.String())
}
users, err := store.ListUsers()
if err != nil {
t.Fatalf("list users: %v", err)
}
if got, want := strings.TrimSpace(response.Body.String()), strings.TrimSpace(renderTemplateFile(t, "admin_users.html", struct {
Users []User
}{Users: users})); got != want {
t.Fatalf("admin users page was not rendered from template file\nwant:\n%s\n\ngot:\n%s", want, got)
}
}
func newTestHandler(t *testing.T) (*Handler, *Store, *http.ServeMux) {
t.Helper()
database := openTestDB(t)
store := NewStore(database)
handler := NewHandler(store, time.Hour)
return handler, store, http.NewServeMux()
}
func sessionCookie(t *testing.T, store *Store, username, password string) *http.Cookie {
t.Helper()
user, err := store.Authenticate(username, password)
if err != nil {
t.Fatalf("authenticate %s: %v", username, err)
}
token, err := store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("create session: %v", err)
}
return &http.Cookie{Name: sessionCookieName, Value: token}
}
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
for _, cookie := range cookies {
if cookie.Name == name {
return cookie
}
}
return nil
}
func readTemplateFile(t *testing.T, name string) string {
t.Helper()
content, err := os.ReadFile(filepath.Join("..", "web", "templates", name))
if err != nil {
t.Fatalf("read template %s: %v", name, err)
}
return string(content)
}
func renderTemplateFile(t *testing.T, name string, data any) string {
t.Helper()
tmpl, err := template.ParseFiles(filepath.Join("..", "web", "templates", name))
if err != nil {
t.Fatalf("parse template %s: %v", name, err)
}
var output bytes.Buffer
if err := tmpl.ExecuteTemplate(&output, name, data); err != nil {
t.Fatalf("execute template %s: %v", name, err)
}
return output.String()
}

50
internal/auth/middleware.go

@ -0,0 +1,50 @@
package auth
import (
"context"
"net/http"
)
const sessionCookieName = "session"
type contextKey string
const userContextKey contextKey = "user"
func RequireAuth(store *Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
session, err := store.GetSession(cookie.Value)
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), userContextKey, session.User)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok || !user.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func UserFromContext(ctx context.Context) (*User, bool) {
user, ok := ctx.Value(userContextKey).(*User)
return user, ok
}

208
internal/auth/store.go

@ -0,0 +1,208 @@
package auth
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
var ErrInvalidCredentials = errors.New("invalid credentials")
var ErrSessionNotFound = errors.New("session not found")
type Store struct {
db *sql.DB
}
type User struct {
ID int64
Username string
IsAdmin bool
}
type Session struct {
Token string
UserID int64
ExpiresAt time.Time
User *User
}
func NewStore(database *sql.DB) *Store {
return &Store{db: database}
}
func (s *Store) EnsureAdmin(username, password string) error {
username = strings.TrimSpace(username)
if username == "" || password == "" {
return fmt.Errorf("admin username and password are required")
}
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return fmt.Errorf("count users: %w", err)
}
if count > 0 {
return nil
}
return s.CreateUser(username, password, true)
}
func (s *Store) CreateUser(username, password string, isAdmin bool) error {
username = strings.TrimSpace(username)
if username == "" {
return fmt.Errorf("username is required")
}
if password == "" {
return fmt.Errorf("password is required")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
adminValue := 0
if isAdmin {
adminValue = 1
}
if _, err := s.db.Exec(
`INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)`,
username,
string(hash),
adminValue,
); err != nil {
return fmt.Errorf("create user: %w", err)
}
return nil
}
func (s *Store) DeleteUser(id int64) error {
if _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil {
return fmt.Errorf("delete user: %w", err)
}
return nil
}
func (s *Store) ListUsers() ([]User, error) {
rows, err := s.db.Query(`SELECT id, username, is_admin FROM users ORDER BY username`)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var user User
var isAdmin int
if err := rows.Scan(&user.ID, &user.Username, &isAdmin); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
user.IsAdmin = isAdmin != 0
users = append(users, user)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate users: %w", err)
}
return users, nil
}
func (s *Store) Authenticate(username, password string) (*User, error) {
var user User
var hash string
var isAdmin int
err := s.db.QueryRow(
`SELECT id, username, password_hash, is_admin FROM users WHERE username = ?`,
strings.TrimSpace(username),
).Scan(&user.ID, &user.Username, &hash, &isAdmin)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidCredentials
}
if err != nil {
return nil, fmt.Errorf("load user: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
user.IsAdmin = isAdmin != 0
return &user, nil
}
func (s *Store) CreateSession(userID int64, ttl time.Duration) (string, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", fmt.Errorf("generate session token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
expiresAt := time.Now().UTC().Add(ttl)
if _, err := s.db.Exec(
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`,
token,
userID,
expiresAt.Format(time.RFC3339Nano),
); err != nil {
return "", fmt.Errorf("create session: %w", err)
}
return token, nil
}
func (s *Store) GetSession(token string) (*Session, error) {
var session Session
var expiresAt string
var user User
var isAdmin int
err := s.db.QueryRow(
`SELECT s.token, s.user_id, s.expires_at, u.username, u.is_admin
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = ?`,
token,
).Scan(&session.Token, &session.UserID, &expiresAt, &user.Username, &isAdmin)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSessionNotFound
}
if err != nil {
return nil, fmt.Errorf("load session: %w", err)
}
parsedExpiresAt, err := time.Parse(time.RFC3339Nano, expiresAt)
if err != nil {
return nil, fmt.Errorf("parse session expiry: %w", err)
}
if !parsedExpiresAt.After(time.Now().UTC()) {
_ = s.DeleteSession(token)
return nil, ErrSessionNotFound
}
user.ID = session.UserID
user.IsAdmin = isAdmin != 0
session.ExpiresAt = parsedExpiresAt
session.User = &user
return &session, nil
}
func (s *Store) DeleteSession(token string) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token); err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
func (s *Store) PurgeExpiredSessions() error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("purge expired sessions: %w", err)
}
return nil
}

101
internal/auth/store_test.go

@ -0,0 +1,101 @@
package auth
import (
"database/sql"
"testing"
"time"
"github.com/domagojzecevic/cammonitor/internal/db"
)
func TestStoreAuthenticateHappyPathAndWrongPassword(t *testing.T) {
database := openTestDB(t)
store := NewStore(database)
if err := store.CreateUser("alice", "secret", false); err != nil {
t.Fatalf("create user: %v", err)
}
user, err := store.Authenticate("alice", "secret")
if err != nil {
t.Fatalf("authenticate valid user: %v", err)
}
if user.Username != "alice" {
t.Fatalf("expected alice, got %q", user.Username)
}
if user.IsAdmin {
t.Fatal("expected regular user")
}
if _, err := store.Authenticate("alice", "wrong"); err == nil {
t.Fatal("expected wrong password to fail")
}
}
func TestStoreExpiredSessionIsRejected(t *testing.T) {
database := openTestDB(t)
store := NewStore(database)
if err := store.CreateUser("alice", "secret", false); err != nil {
t.Fatalf("create user: %v", err)
}
user, err := store.Authenticate("alice", "secret")
if err != nil {
t.Fatalf("authenticate: %v", err)
}
token, err := store.CreateSession(user.ID, -time.Minute)
if err != nil {
t.Fatalf("create session: %v", err)
}
if _, err := store.GetSession(token); err == nil {
t.Fatal("expected expired session to fail")
}
}
func TestEnsureAdminCreatesFirstRunAdminOnlyWhenEmpty(t *testing.T) {
database := openTestDB(t)
store := NewStore(database)
if err := store.EnsureAdmin("admin", "secret"); err != nil {
t.Fatalf("ensure admin: %v", err)
}
users, err := store.ListUsers()
if err != nil {
t.Fatalf("list users: %v", err)
}
if len(users) != 1 || users[0].Username != "admin" || !users[0].IsAdmin {
t.Fatalf("unexpected users after bootstrap: %#v", users)
}
if err := store.EnsureAdmin("other", "secret"); err != nil {
t.Fatalf("ensure admin second run: %v", err)
}
users, err = store.ListUsers()
if err != nil {
t.Fatalf("list users second run: %v", err)
}
if len(users) != 1 {
t.Fatalf("expected no second bootstrap user, got %d", len(users))
}
}
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
database, err := db.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
})
return database
}

30
internal/db/db.go

@ -34,5 +34,35 @@ func Open(path string) (*sql.DB, error) {
return nil, fmt.Errorf("ping sqlite: %w", err) return nil, fmt.Errorf("ping sqlite: %w", err)
} }
if err := migrate(database); err != nil {
database.Close()
return nil, err
}
return database, nil return database, nil
} }
func migrate(database *sql.DB) error {
statements := []string{
`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
)`,
}
for _, statement := range statements {
if _, err := database.Exec(statement); err != nil {
return fmt.Errorf("run migration: %w", err)
}
}
return nil
}

104
internal/footage/index.go

@ -0,0 +1,104 @@
package footage
import (
"sort"
"sync"
"time"
)
type Index struct {
root string
interval time.Duration
mu sync.RWMutex
days map[string]DayEntry
done chan struct{}
once sync.Once
}
func NewIndex(root string, interval time.Duration) *Index {
index := &Index{
root: root,
interval: interval,
days: make(map[string]DayEntry),
done: make(chan struct{}),
}
index.rescan()
if interval > 0 {
go index.run()
}
return index
}
func (i *Index) DayList() []string {
i.mu.RLock()
defer i.mu.RUnlock()
days := make([]string, 0, len(i.days))
for day := range i.days {
days = append(days, day)
}
sort.Sort(sort.Reverse(sort.StringSlice(days)))
return days
}
func (i *Index) Day(date string) (*DayEntry, bool) {
i.mu.RLock()
defer i.mu.RUnlock()
entry, ok := i.days[date]
if !ok {
return nil, false
}
copied := copyDayEntry(entry)
return &copied, true
}
func (i *Index) Close() {
i.once.Do(func() {
close(i.done)
})
}
func (i *Index) run() {
ticker := time.NewTicker(i.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
i.rescan()
case <-i.done:
return
}
}
}
func (i *Index) rescan() {
days, err := Scan(i.root)
if err != nil {
return
}
next := make(map[string]DayEntry, len(days))
for _, day := range days {
next[day.Date] = copyDayEntry(day)
}
i.mu.Lock()
i.days = next
i.mu.Unlock()
}
func copyDayEntry(entry DayEntry) DayEntry {
copied := DayEntry{
Date: entry.Date,
Images: append([]ImageFile(nil), entry.Images...),
Videos: append([]VideoFile(nil), entry.Videos...),
}
return copied
}

96
internal/footage/index_test.go

@ -0,0 +1,96 @@
package footage
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestIndexListsDaysNewestFirstAndReturnsCopies(t *testing.T) {
root := filepath.Join("..", "..", "testdata", "footage")
index := NewIndex(root, time.Hour)
t.Cleanup(index.Close)
days := index.DayList()
if len(days) != 2 {
t.Fatalf("expected 2 days, got %d", len(days))
}
if days[0] != "20260102" || days[1] != "20260101" {
t.Fatalf("expected days sorted newest first, got %#v", days)
}
entry, ok := index.Day("20260101")
if !ok {
t.Fatalf("expected day 20260101")
}
entry.Images = nil
entryAgain, ok := index.Day("20260101")
if !ok {
t.Fatalf("expected day 20260101 on second lookup")
}
if len(entryAgain.Images) != 2 {
t.Fatalf("expected index day copy to preserve images, got %d", len(entryAgain.Images))
}
}
func TestIndexPeriodicRescan(t *testing.T) {
root := t.TempDir()
writeFixtureFile(t, root, filepath.Join("20260101", "images", "A26010112000001.jpg"))
index := NewIndex(root, 10*time.Millisecond)
t.Cleanup(index.Close)
if days := index.DayList(); len(days) != 1 || days[0] != "20260101" {
t.Fatalf("expected initial day 20260101, got %#v", days)
}
writeFixtureFile(t, root, filepath.Join("20260102", "record", "A260102_130000_130015.265"))
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
days := index.DayList()
if len(days) == 2 && days[0] == "20260102" && days[1] == "20260101" {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for rescan to include new day, got %#v", index.DayList())
}
func TestIndexConcurrentAccessDuringRescan(t *testing.T) {
root := t.TempDir()
writeFixtureFile(t, root, filepath.Join("20260101", "images", "A26010112000001.jpg"))
index := NewIndex(root, time.Millisecond)
t.Cleanup(index.Close)
var wg sync.WaitGroup
for worker := 0; worker < 8; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
_ = index.DayList()
_, _ = index.Day("20260101")
}
}()
}
wg.Wait()
}
func writeFixtureFile(t *testing.T, root, relPath string) {
t.Helper()
path := filepath.Join(root, relPath)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create fixture dir: %v", err)
}
if err := os.WriteFile(path, []byte("fixture"), 0o644); err != nil {
t.Fatalf("write fixture file: %v", err)
}
}

212
internal/footage/scanner.go

@ -0,0 +1,212 @@
package footage
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"time"
)
var (
imageFilenamePattern = regexp.MustCompile(`^A(\d{6})(\d{6})\d+\.jpg$`)
videoFilenamePattern = regexp.MustCompile(`^A(\d{6})_(\d{6})_(\d{6})\.265$`)
)
type DayEntry struct {
Date string
Images []ImageFile
Videos []VideoFile
}
type ImageFile struct {
RelPath string
Filename string
Timestamp time.Time
}
type VideoFile struct {
RelPath string
Filename string
StartTime time.Time
EndTime time.Time
Duration time.Duration
}
func Scan(root string) ([]DayEntry, error) {
dayDirs, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read footage root: %w", err)
}
days := make([]DayEntry, 0, len(dayDirs))
for _, dayDir := range dayDirs {
if !dayDir.IsDir() {
continue
}
if _, err := time.ParseInLocation("20060102", dayDir.Name(), time.Local); err != nil {
continue
}
entry := DayEntry{Date: dayDir.Name()}
dayPath := filepath.Join(root, dayDir.Name())
images, err := scanImages(root, dayPath)
if err != nil {
return nil, err
}
entry.Images = images
videos, err := scanVideos(root, dayPath)
if err != nil {
return nil, err
}
entry.Videos = videos
days = append(days, entry)
}
sort.Slice(days, func(i, j int) bool {
return days[i].Date > days[j].Date
})
return days, nil
}
func scanImages(root, dayPath string) ([]ImageFile, error) {
imageDir := filepath.Join(dayPath, "images")
entries, err := os.ReadDir(imageDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read image dir %s: %w", imageDir, err)
}
images := make([]ImageFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
return nil, fmt.Errorf("read image info %s: %w", entry.Name(), err)
}
if !info.Mode().Type().IsRegular() {
continue
}
image, err := parseImageFilename(entry.Name())
if err != nil {
continue
}
image.RelPath = mustRel(root, filepath.Join(imageDir, entry.Name()))
images = append(images, image)
}
sort.Slice(images, func(i, j int) bool {
if images[i].Timestamp.Equal(images[j].Timestamp) {
return images[i].Filename < images[j].Filename
}
return images[i].Timestamp.Before(images[j].Timestamp)
})
return images, nil
}
func scanVideos(root, dayPath string) ([]VideoFile, error) {
recordDir := filepath.Join(dayPath, "record")
entries, err := os.ReadDir(recordDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read record dir %s: %w", recordDir, err)
}
videos := make([]VideoFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
return nil, fmt.Errorf("read video info %s: %w", entry.Name(), err)
}
if !info.Mode().Type().IsRegular() {
continue
}
video, err := parseVideoFilename(entry.Name())
if err != nil {
continue
}
video.RelPath = mustRel(root, filepath.Join(recordDir, entry.Name()))
videos = append(videos, video)
}
sort.Slice(videos, func(i, j int) bool {
if videos[i].StartTime.Equal(videos[j].StartTime) {
return videos[i].Filename < videos[j].Filename
}
return videos[i].StartTime.Before(videos[j].StartTime)
})
return videos, nil
}
func parseImageFilename(name string) (ImageFile, error) {
matches := imageFilenamePattern.FindStringSubmatch(name)
if matches == nil {
return ImageFile{}, fmt.Errorf("invalid image filename %q", name)
}
timestamp, err := time.ParseInLocation("060102150405", matches[1]+matches[2], time.Local)
if err != nil {
return ImageFile{}, fmt.Errorf("parse image timestamp %q: %w", name, err)
}
return ImageFile{
Filename: name,
Timestamp: timestamp,
}, nil
}
func parseVideoFilename(name string) (VideoFile, error) {
matches := videoFilenamePattern.FindStringSubmatch(name)
if matches == nil {
return VideoFile{}, fmt.Errorf("invalid video filename %q", name)
}
start, err := time.ParseInLocation("060102150405", matches[1]+matches[2], time.Local)
if err != nil {
return VideoFile{}, fmt.Errorf("parse video start timestamp %q: %w", name, err)
}
end, err := time.ParseInLocation("060102150405", matches[1]+matches[3], time.Local)
if err != nil {
return VideoFile{}, fmt.Errorf("parse video end timestamp %q: %w", name, err)
}
if end.Before(start) {
end = end.Add(24 * time.Hour)
}
return VideoFile{
Filename: name,
StartTime: start,
EndTime: end,
Duration: end.Sub(start),
}, nil
}
func mustRel(root, path string) string {
relPath, err := filepath.Rel(root, path)
if err != nil {
return path
}
return relPath
}

89
internal/footage/scanner_test.go

@ -0,0 +1,89 @@
package footage
import (
"path/filepath"
"testing"
"time"
)
func TestScanIndexesFixtureFootage(t *testing.T) {
root := filepath.Join("..", "..", "testdata", "footage")
days, err := Scan(root)
if err != nil {
t.Fatalf("scan fixture footage: %v", err)
}
if len(days) != 2 {
t.Fatalf("expected 2 days, got %d", len(days))
}
if days[0].Date != "20260102" || days[1].Date != "20260101" {
t.Fatalf("expected days sorted newest first, got %#v", []string{days[0].Date, days[1].Date})
}
day := days[1]
if len(day.Images) != 2 {
t.Fatalf("expected 2 images for %s, got %d", day.Date, len(day.Images))
}
if got := day.Images[0].RelPath; got != filepath.Join("20260101", "images", "A26010112000001.jpg") {
t.Fatalf("unexpected first image path %q", got)
}
if got := day.Images[1].RelPath; got != filepath.Join("20260101", "images", "A26010112050001.jpg") {
t.Fatalf("unexpected second image path %q", got)
}
if !day.Images[0].Timestamp.Before(day.Images[1].Timestamp) {
t.Fatalf("expected images sorted by timestamp ascending")
}
if len(day.Videos) != 2 {
t.Fatalf("expected 2 videos for %s, got %d", day.Date, len(day.Videos))
}
if got := day.Videos[0].RelPath; got != filepath.Join("20260101", "record", "A260101_120000_120015.265") {
t.Fatalf("unexpected first video path %q", got)
}
if got := day.Videos[0].Duration; got != 15*time.Second {
t.Fatalf("expected first video duration 15s, got %s", got)
}
if !day.Videos[0].StartTime.Before(day.Videos[1].StartTime) {
t.Fatalf("expected videos sorted by start time ascending")
}
}
func TestParseImageFilename(t *testing.T) {
image, err := parseImageFilename("A26010112000001.jpg")
if err != nil {
t.Fatalf("parse image filename: %v", err)
}
if image.Filename != "A26010112000001.jpg" {
t.Fatalf("unexpected filename %q", image.Filename)
}
want := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.Local)
if !image.Timestamp.Equal(want) {
t.Fatalf("expected timestamp %s, got %s", want, image.Timestamp)
}
}
func TestParseVideoFilename(t *testing.T) {
video, err := parseVideoFilename("A260101_120000_120015.265")
if err != nil {
t.Fatalf("parse video filename: %v", err)
}
if video.Filename != "A260101_120000_120015.265" {
t.Fatalf("unexpected filename %q", video.Filename)
}
if video.Duration != 15*time.Second {
t.Fatalf("expected duration 15s, got %s", video.Duration)
}
}
func TestParseFilenameRejectsUnknownPattern(t *testing.T) {
if _, err := parseImageFilename("camera.jpg"); err == nil {
t.Fatalf("expected invalid image filename to fail")
}
if _, err := parseVideoFilename("camera.265"); err == nil {
t.Fatalf("expected invalid video filename to fail")
}
}

257
internal/image/handler.go

@ -0,0 +1,257 @@
package image
import (
"fmt"
"html/template"
"net/http"
"net/url"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/footage"
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates"
"github.com/go-chi/chi/v5"
)
type Handler struct {
cfg *config.Config
idx *footage.Index
thumbs *Cache
}
type ShellData struct {
Title string
User *auth.User
Months []MonthGroup
CurrentDate string
ActiveTab string
Content any
}
type MonthGroup struct {
Label string
Days []string
}
type PageData struct {
Date string
Images []ImageItem
ActiveIndex int
PrevIndex int
NextIndex int
Current ImageItem
}
type ImageItem struct {
Index int
Filename string
RelPath string
RawURL string
ThumbURL string
Active bool
}
func NewHandler(cfg *config.Config, idx *footage.Index) *Handler {
return &Handler{
cfg: cfg,
idx: idx,
thumbs: NewCache(defaultCacheEntries),
}
}
func (h *Handler) ServeRaw(w http.ResponseWriter, r *http.Request) {
absPath, ok := h.resolveRequestPath(w, r)
if !ok {
return
}
http.ServeFile(w, r, absPath)
}
func (h *Handler) ServeThumb(w http.ResponseWriter, r *http.Request) {
absPath, ok := h.resolveRequestPath(w, r)
if !ok {
return
}
thumb, err := h.thumbs.Thumbnail(absPath)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "max-age=3600")
w.Header().Set("Content-Type", "image/jpeg")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(thumb)
}
func (h *Handler) ServePage(w http.ResponseWriter, r *http.Request) {
date := chi.URLParam(r, "date")
day, ok := h.day(date)
if !ok {
http.NotFound(w, r)
return
}
data := h.pageData(date, day.Images, parseIndex(r.URL.Query().Get("idx"), len(day.Images)))
h.render(w, r, ShellData{
Title: "CamMonitor " + date + " images",
CurrentDate: date,
ActiveTab: "images",
Content: data,
})
}
func (h *Handler) pageData(date string, files []footage.ImageFile, active int) PageData {
items := make([]ImageItem, 0, len(files))
for i, file := range files {
items = append(items, ImageItem{
Index: i,
Filename: file.Filename,
RelPath: filepath.ToSlash(file.RelPath),
RawURL: "/raw/image/" + pathEscape(file.RelPath),
ThumbURL: "/thumb/image/" + pathEscape(file.RelPath),
Active: i == active,
})
}
page := PageData{
Date: date,
Images: items,
ActiveIndex: active,
}
if len(items) > 0 {
page.PrevIndex = (active - 1 + len(items)) % len(items)
page.NextIndex = (active + 1) % len(items)
page.Current = items[active]
}
return page
}
func (h *Handler) resolveRequestPath(w http.ResponseWriter, r *http.Request) (string, bool) {
absPath, err := h.resolveRelPath(chi.URLParam(r, "*"))
if err != nil {
http.Error(w, "invalid image path", http.StatusBadRequest)
return "", false
}
return absPath, true
}
func (h *Handler) resolveRelPath(relPath string) (string, error) {
if h.cfg == nil || h.cfg.FootageRoot == "" {
return "", fmt.Errorf("missing footage root")
}
if relPath == "" {
return "", fmt.Errorf("missing path")
}
clean := filepath.Clean(filepath.FromSlash(relPath))
if clean == "." || clean == ".." || filepath.IsAbs(clean) || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("unsafe path")
}
root, err := filepath.Abs(h.cfg.FootageRoot)
if err != nil {
return "", err
}
target, err := filepath.Abs(filepath.Join(root, clean))
if err != nil {
return "", err
}
rel, err := filepath.Rel(root, target)
if err != nil {
return "", err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes footage root")
}
return target, nil
}
func (h *Handler) render(w http.ResponseWriter, r *http.Request, data ShellData) {
data.User, _ = auth.UserFromContext(r.Context())
data.Months = groupDaysByMonth(h.dayList())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := imageTemplate.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "render image page failed", http.StatusInternalServerError)
}
}
func (h *Handler) dayList() []string {
if h.idx == nil {
return nil
}
return h.idx.DayList()
}
func (h *Handler) day(date string) (*footage.DayEntry, bool) {
if h.idx == nil {
return nil, false
}
return h.idx.Day(date)
}
func parseIndex(value string, length int) int {
if length <= 0 {
return 0
}
index, err := strconv.Atoi(value)
if err != nil || index < 0 || index >= length {
return 0
}
return index
}
func pathEscape(relPath string) string {
parts := strings.Split(filepath.ToSlash(relPath), "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
func groupDaysByMonth(days []string) []MonthGroup {
groups := make(map[string][]string)
labels := make([]string, 0)
for _, day := range days {
if len(day) != len("20060102") {
continue
}
parsed, err := time.Parse("20060102", day)
if err != nil {
continue
}
label := parsed.Format("2006-01")
if _, ok := groups[label]; !ok {
labels = append(labels, label)
}
groups[label] = append(groups[label], day)
}
sort.Sort(sort.Reverse(sort.StringSlice(labels)))
monthGroups := make([]MonthGroup, 0, len(labels))
for _, label := range labels {
monthGroups = append(monthGroups, MonthGroup{
Label: label,
Days: groups[label],
})
}
return monthGroups
}
var imageTemplate = template.Must(template.ParseFS(
webtemplates.FS,
webtemplates.Base,
webtemplates.Images,
))

175
internal/image/handler_test.go

@ -0,0 +1,175 @@
package image
import (
"bytes"
stdimage "image"
"image/color"
"image/jpeg"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/footage"
"github.com/go-chi/chi/v5"
)
func TestThumbnailEndpointReturnsSmallJPEG(t *testing.T) {
root := createImageFixture(t)
index := footage.NewIndex(root, 0)
t.Cleanup(index.Close)
router := imageRouter(root, index)
request := httptest.NewRequest(http.MethodGet, "/thumb/image/20260101/images/A26010112000001.jpg", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String())
}
if contentType := response.Header().Get("Content-Type"); contentType != "image/jpeg" {
t.Fatalf("expected image/jpeg content type, got %q", contentType)
}
if response.Body.Len() > 30*1024 {
t.Fatalf("expected thumbnail <= 30 KB, got %d bytes", response.Body.Len())
}
thumbnail, err := jpeg.Decode(bytes.NewReader(response.Body.Bytes()))
if err != nil {
t.Fatalf("decode thumbnail: %v", err)
}
bounds := thumbnail.Bounds()
if bounds.Dx() > 160 || bounds.Dy() > 90 {
t.Fatalf("expected thumbnail within 160x90, got %dx%d", bounds.Dx(), bounds.Dy())
}
}
func TestRawEndpointServesOriginalFile(t *testing.T) {
root := createImageFixture(t)
originalPath := filepath.Join(root, "20260101", "images", "A26010112000001.jpg")
original, err := os.ReadFile(originalPath)
if err != nil {
t.Fatalf("read original: %v", err)
}
index := footage.NewIndex(root, 0)
t.Cleanup(index.Close)
router := imageRouter(root, index)
request := httptest.NewRequest(http.MethodGet, "/raw/image/20260101/images/A26010112000001.jpg", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String())
}
served, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read response: %v", err)
}
if !bytes.Equal(served, original) {
t.Fatalf("expected raw endpoint to serve original bytes unchanged")
}
}
func TestRawEndpointRejectsTraversal(t *testing.T) {
root := createImageFixture(t)
index := footage.NewIndex(root, 0)
t.Cleanup(index.Close)
router := imageRouter(root, index)
request := httptest.NewRequest(http.MethodGet, "/raw/image/%2e%2e/secret.jpg", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, response.Code)
}
}
func TestImagePageRendersViewerNavigation(t *testing.T) {
root := createImageFixture(t)
index := footage.NewIndex(root, 0)
t.Cleanup(index.Close)
router := imageRouter(root, index)
request := httptest.NewRequest(http.MethodGet, "/day/20260101/images?idx=1", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{
`data-image-browser`,
`data-active-index="1"`,
`/thumb/image/20260101/images/A26010112000001.jpg`,
`/raw/image/20260101/images/A26010112050001.jpg`,
`data-prev-index="0"`,
`data-next-index="0"`,
`keydown`,
`ArrowLeft`,
`ArrowRight`,
`ring-2 ring-indigo-400`,
} {
if !bytes.Contains(response.Body.Bytes(), []byte(want)) {
t.Fatalf("expected response to contain %q\nbody:\n%s", want, body)
}
}
}
func imageRouter(root string, index *footage.Index) chi.Router {
handler := NewHandler(&config.Config{FootageRoot: root}, index)
router := chi.NewRouter()
router.Get("/raw/image/*", handler.ServeRaw)
router.Get("/thumb/image/*", handler.ServeThumb)
router.Get("/day/{date}/images", handler.ServePage)
return router
}
func createImageFixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
imageDir := filepath.Join(root, "20260101", "images")
if err := os.MkdirAll(imageDir, 0o755); err != nil {
t.Fatalf("create image dir: %v", err)
}
writeJPEG(t, filepath.Join(imageDir, "A26010112000001.jpg"), color.RGBA{R: 220, G: 40, B: 40, A: 255})
writeJPEG(t, filepath.Join(imageDir, "A26010112050001.jpg"), color.RGBA{R: 40, G: 90, B: 220, A: 255})
return root
}
func writeJPEG(t *testing.T, path string, fill color.Color) {
t.Helper()
img := stdimage.NewRGBA(stdimage.Rect(0, 0, 320, 180))
for y := 0; y < img.Bounds().Dy(); y++ {
for x := 0; x < img.Bounds().Dx(); x++ {
img.Set(x, y, fill)
}
}
file, err := os.Create(path)
if err != nil {
t.Fatalf("create jpeg %s: %v", path, err)
}
defer file.Close()
if err := jpeg.Encode(file, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("encode jpeg %s: %v", path, err)
}
}

114
internal/image/thumb.go

@ -0,0 +1,114 @@
package image
import (
"bytes"
stdimage "image"
"image/jpeg"
"os"
"sync"
xdraw "golang.org/x/image/draw"
)
const (
defaultCacheEntries = 500
thumbnailWidth = 160
thumbnailHeight = 90
)
type Cache struct {
mu sync.Mutex
max int
entries map[string][]byte
order []string
}
func NewCache(max int) *Cache {
if max <= 0 {
max = defaultCacheEntries
}
return &Cache{
max: max,
entries: make(map[string][]byte),
}
}
func (c *Cache) Thumbnail(absPath string) ([]byte, error) {
if cached, ok := c.get(absPath); ok {
return cached, nil
}
file, err := os.Open(absPath)
if err != nil {
return nil, err
}
defer file.Close()
src, err := jpeg.Decode(file)
if err != nil {
return nil, err
}
dst := resizeToFit(src, thumbnailWidth, thumbnailHeight)
var out bytes.Buffer
if err := jpeg.Encode(&out, dst, &jpeg.Options{Quality: 75}); err != nil {
return nil, err
}
thumb := out.Bytes()
c.add(absPath, thumb)
return append([]byte(nil), thumb...), nil
}
func (c *Cache) get(key string) ([]byte, bool) {
c.mu.Lock()
defer c.mu.Unlock()
value, ok := c.entries[key]
if !ok {
return nil, false
}
return append([]byte(nil), value...), true
}
func (c *Cache) add(key string, value []byte) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.entries[key]; !ok {
c.order = append(c.order, key)
}
c.entries[key] = append([]byte(nil), value...)
for len(c.order) > c.max {
oldest := c.order[0]
c.order = c.order[1:]
delete(c.entries, oldest)
}
}
func resizeToFit(src stdimage.Image, maxWidth, maxHeight int) stdimage.Image {
bounds := src.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width <= 0 || height <= 0 {
return stdimage.NewRGBA(stdimage.Rect(0, 0, 1, 1))
}
targetWidth := maxWidth
targetHeight := height * targetWidth / width
if targetHeight > maxHeight {
targetHeight = maxHeight
targetWidth = width * targetHeight / height
}
if targetWidth < 1 {
targetWidth = 1
}
if targetHeight < 1 {
targetHeight = 1
}
dst := stdimage.NewRGBA(stdimage.Rect(0, 0, targetWidth, targetHeight))
xdraw.BiLinear.Scale(dst, dst.Bounds(), src, bounds, xdraw.Over, nil)
return dst
}

137
internal/web/handler.go

@ -0,0 +1,137 @@
package web
import (
"html/template"
"net/http"
"sort"
"time"
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/footage"
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates"
"github.com/go-chi/chi/v5"
)
type Handler struct {
index *footage.Index
}
type ShellData struct {
Title string
User *auth.User
Months []MonthGroup
CurrentDate string
ActiveTab string
Content any
}
type MonthGroup struct {
Label string
Days []string
}
type DayPageData struct {
Date string
ImageCount int
VideoCount int
}
func NewHandler(index *footage.Index) *Handler {
return &Handler{index: index}
}
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
days := h.dayList()
if len(days) == 0 {
h.render(w, r, ShellData{
Title: "CamMonitor",
Content: DayPageData{
Date: "No footage",
},
})
return
}
http.Redirect(w, r, "/day/"+days[0]+"/images", http.StatusFound)
}
func (h *Handler) DayOverview(w http.ResponseWriter, r *http.Request) {
date := chi.URLParam(r, "date")
day, ok := h.day(date)
if !ok {
http.NotFound(w, r)
return
}
h.render(w, r, ShellData{
Title: "CamMonitor " + date,
CurrentDate: date,
Content: DayPageData{
Date: date,
ImageCount: len(day.Images),
VideoCount: len(day.Videos),
},
})
}
func (h *Handler) render(w http.ResponseWriter, r *http.Request, data ShellData) {
data.User, _ = auth.UserFromContext(r.Context())
data.Months = groupDaysByMonth(h.dayList())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := shellTemplate.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "render page failed", http.StatusInternalServerError)
}
}
func (h *Handler) dayList() []string {
if h.index == nil {
return nil
}
return h.index.DayList()
}
func (h *Handler) day(date string) (*footage.DayEntry, bool) {
if h.index == nil {
return nil, false
}
return h.index.Day(date)
}
func groupDaysByMonth(days []string) []MonthGroup {
groups := make(map[string][]string)
labels := make([]string, 0)
for _, day := range days {
if len(day) != len("20060102") {
continue
}
parsed, err := time.Parse("20060102", day)
if err != nil {
continue
}
label := parsed.Format("2006-01")
if _, ok := groups[label]; !ok {
labels = append(labels, label)
}
groups[label] = append(groups[label], day)
}
sort.Sort(sort.Reverse(sort.StringSlice(labels)))
monthGroups := make([]MonthGroup, 0, len(labels))
for _, label := range labels {
monthGroups = append(monthGroups, MonthGroup{
Label: label,
Days: groups[label],
})
}
return monthGroups
}
var shellTemplate = template.Must(template.ParseFS(
webtemplates.FS,
webtemplates.Base,
webtemplates.Day,
))

35
internal/web/router.go

@ -5,11 +5,14 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/config" "github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/footage"
imagebrowser "github.com/domagojzecevic/cammonitor/internal/image"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
func NewRouter(_ *config.Config, _ *sql.DB, _ any) chi.Router { func NewRouter(cfg *config.Config, database *sql.DB, index *footage.Index) chi.Router {
router := chi.NewRouter() router := chi.NewRouter()
router.Get("/health", func(w http.ResponseWriter, _ *http.Request) { router.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
@ -18,5 +21,35 @@ func NewRouter(_ *config.Config, _ *sql.DB, _ any) chi.Router {
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}) })
if cfg == nil || database == nil {
return router
}
store := auth.NewStore(database)
authHandler := auth.NewHandler(store, cfg.SessionTTL)
webHandler := NewHandler(index)
imageHandler := imagebrowser.NewHandler(cfg, index)
router.Get("/login", authHandler.LoginPage)
router.Post("/login", authHandler.Login)
router.Post("/logout", authHandler.Logout)
router.Group(func(protected chi.Router) {
protected.Use(auth.RequireAuth(store))
protected.Get("/", webHandler.Index)
protected.Get("/day/{date}", webHandler.DayOverview)
protected.Get("/day/{date}/images", imageHandler.ServePage)
protected.Get("/raw/image/*", imageHandler.ServeRaw)
protected.Get("/thumb/image/*", imageHandler.ServeThumb)
protected.Group(func(admin chi.Router) {
admin.Use(auth.RequireAdmin)
admin.Get("/admin/users", authHandler.UsersPage)
admin.Post("/admin/users", authHandler.CreateUser)
admin.Post("/admin/users/{id}/delete", authHandler.DeleteUser)
})
})
return router return router
} }

188
internal/web/router_test.go

@ -4,7 +4,16 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing" "testing"
"time"
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/db"
"github.com/domagojzecevic/cammonitor/internal/footage"
) )
func TestHealthReturnsOK(t *testing.T) { func TestHealthReturnsOK(t *testing.T) {
@ -28,3 +37,182 @@ func TestHealthReturnsOK(t *testing.T) {
t.Fatalf("expected status ok, got %q", body["status"]) t.Fatalf("expected status ok, got %q", body["status"])
} }
} }
func TestAdminUsersRedirectsWithoutSessionCookie(t *testing.T) {
database, err := db.Open(filepath.Join(t.TempDir(), "cammonitor.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
})
router := NewRouter(&config.Config{SessionTTL: time.Hour}, database, nil)
request := httptest.NewRequest(http.MethodGet, "/admin/users", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, response.Code)
}
if location := response.Header().Get("Location"); location != "/login" {
t.Fatalf("expected redirect to /login, got %q", location)
}
}
func TestIndexRedirectsToNewestDayImages(t *testing.T) {
router, cookie := newAuthenticatedRouter(t)
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, response.Code)
}
if location := response.Header().Get("Location"); location != "/day/20260102/images" {
t.Fatalf("expected redirect to newest day images, got %q", location)
}
}
func TestDayOverviewRendersShellNavigationAndCounts(t *testing.T) {
router, cookie := newAuthenticatedRouter(t)
request := httptest.NewRequest(http.MethodGet, "/day/20260101", nil)
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, response.Code)
}
body := response.Body.String()
for _, want := range []string{
`https://cdn.tailwindcss.com`,
`class="hidden w-56 shrink-0 border-r border-slate-800 bg-slate-950 md:block"`,
`data-mobile-drawer`,
`class="fixed inset-x-0 bottom-0 z-20 grid grid-cols-2 border-t border-slate-800 bg-slate-950/95 md:hidden"`,
`2026-01`,
`href="/day/20260102"`,
`href="/day/20260101"`,
`Images (2)`,
`Videos (2)`,
`href="/day/20260101/images"`,
`href="/day/20260101/videos"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected response to contain %q\nbody:\n%s", want, body)
}
}
}
func TestDayOverviewReturnsNotFoundForMissingDay(t *testing.T) {
router, cookie := newAuthenticatedRouter(t)
request := httptest.NewRequest(http.MethodGet, "/day/20260103", nil)
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, response.Code)
}
}
func TestImagePageRendersAuthenticatedBrowser(t *testing.T) {
router, cookie := newAuthenticatedRouter(t)
request := httptest.NewRequest(http.MethodGet, "/day/20260101/images?idx=1", nil)
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, response.Code)
}
body := response.Body.String()
for _, want := range []string{
`data-image-browser`,
`data-active-index="1"`,
`href="/day/20260101/images?idx=0"`,
`href="/day/20260101/images?idx=1"`,
`src="/raw/image/20260101/images/A26010112050001.jpg"`,
`src="/thumb/image/20260101/images/A26010112000001.jpg"`,
`data-prev-index="0"`,
`data-next-index="0"`,
`keydown`,
`ArrowLeft`,
`ArrowRight`,
`ring-2 ring-indigo-400`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected response to contain %q\nbody:\n%s", want, body)
}
}
}
func newAuthenticatedRouter(t *testing.T) (http.Handler, *http.Cookie) {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "cammonitor.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
})
store := auth.NewStore(database)
if err := store.EnsureAdmin("admin", "secret"); err != nil {
t.Fatalf("ensure admin: %v", err)
}
footageRoot := filepath.Join("..", "..", "testdata", "footage")
index := footage.NewIndex(footageRoot, time.Hour)
t.Cleanup(index.Close)
router := NewRouter(&config.Config{FootageRoot: footageRoot, SessionTTL: time.Hour}, database, index)
cookie := login(t, router)
return router, cookie
}
func login(t *testing.T, router http.Handler) *http.Cookie {
t.Helper()
form := url.Values{
"username": {"admin"},
"password": {"secret"},
}
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected login status %d, got %d", http.StatusSeeOther, response.Code)
}
for _, cookie := range response.Result().Cookies() {
if cookie.Name == "session" && cookie.Value != "" {
return cookie
}
}
t.Fatalf("expected login response to set session cookie")
return nil
}

39
internal/web/templates/admin_users.html

@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Users - CamMonitor</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-950 p-6 text-slate-100">
<main class="mx-auto max-w-3xl space-y-6">
<header class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Users</h1>
<form method="post" action="/logout"><button class="rounded bg-slate-800 px-3 py-2">Sign out</button></form>
</header>
<form method="post" action="/admin/users" class="grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]">
<input name="username" placeholder="Username" class="rounded bg-slate-900 px-3 py-2 ring-1 ring-slate-700">
<input name="password" type="password" placeholder="Password" class="rounded bg-slate-900 px-3 py-2 ring-1 ring-slate-700">
<label class="flex items-center gap-2 text-sm"><input name="is_admin" type="checkbox"> Admin</label>
<button class="rounded bg-indigo-500 px-3 py-2 font-medium text-white">Add</button>
</form>
<table class="w-full border-collapse text-left">
<thead><tr class="border-b border-slate-800"><th class="py-2">Username</th><th class="py-2">Role</th><th class="py-2"></th></tr></thead>
<tbody>
{{range .Users}}
<tr class="border-b border-slate-900">
<td class="py-2">{{.Username}}</td>
<td class="py-2">{{if .IsAdmin}}admin{{else}}user{{end}}</td>
<td class="py-2 text-right">
<form method="post" action="/admin/users/{{.ID}}/delete">
<button class="rounded bg-slate-800 px-3 py-1">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</main>
</body>
</html>

72
internal/web/templates/base.html

@ -0,0 +1,72 @@
{{define "base"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-900 text-slate-100">
<div class="flex min-h-screen">
<aside class="hidden w-56 shrink-0 border-r border-slate-800 bg-slate-950 md:block">
<div class="px-4 py-4">
<a href="/" class="text-base font-semibold text-slate-100">CamMonitor</a>
</div>
<nav class="px-3 pb-6">
{{range .Months}}
<div class="mt-5 first:mt-0">
<div class="px-2 text-xs font-semibold uppercase tracking-wide text-slate-500">{{.Label}}</div>
<div class="mt-2 space-y-1">
{{range .Days}}
<a href="/day/{{.}}" class="block rounded-md px-2 py-2 text-sm text-slate-300 hover:bg-slate-800 hover:text-white">{{.}}</a>
{{end}}
</div>
</div>
{{end}}
</nav>
</aside>
<main class="flex min-h-screen flex-1 flex-col pb-16 md:pb-0">
<header class="sticky top-0 z-10 border-b border-slate-800 bg-slate-900/95">
<div class="flex h-14 items-center justify-between px-4 md:px-6">
<div class="flex items-center gap-3">
<details data-mobile-drawer class="relative md:hidden">
<summary class="flex h-9 w-9 cursor-pointer list-none items-center justify-center rounded-md border border-slate-700 text-slate-100">&#9776;</summary>
<div class="absolute left-0 top-11 z-30 w-72 border border-slate-800 bg-slate-950 p-4 shadow-xl">
{{range .Months}}
<div class="mt-5 first:mt-0">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{.Label}}</div>
<div class="mt-2 space-y-1">
{{range .Days}}
<a href="/day/{{.}}" class="block rounded-md px-2 py-2 text-sm text-slate-300 hover:bg-slate-800 hover:text-white">{{.}}</a>
{{end}}
</div>
</div>
{{end}}
</div>
</details>
<a href="/" class="text-base font-semibold text-slate-100 md:hidden">CamMonitor</a>
</div>
<div class="flex items-center gap-3 text-sm text-slate-300">
{{with .User}}<span>{{.Username}}</span>{{end}}
<form method="post" action="/logout">
<button type="submit" class="rounded-md border border-slate-700 px-3 py-1.5 text-sm text-slate-100 hover:bg-slate-800">Logout</button>
</form>
</div>
</div>
</header>
<section class="flex-1 px-4 py-6 md:px-8">
{{template "content" .}}
</section>
</main>
</div>
<nav class="fixed inset-x-0 bottom-0 z-20 grid grid-cols-2 border-t border-slate-800 bg-slate-950/95 md:hidden">
<a href="{{if .CurrentDate}}/day/{{.CurrentDate}}/images{{else}}/{{end}}" class="px-4 py-3 text-center text-sm font-medium text-slate-200">Images</a>
<a href="{{if .CurrentDate}}/day/{{.CurrentDate}}/videos{{else}}/{{end}}" class="px-4 py-3 text-center text-sm font-medium text-slate-200">Videos</a>
</nav>
</body>
</html>
{{end}}

16
internal/web/templates/day.html

@ -0,0 +1,16 @@
{{define "content"}}
{{with .Content}}
<div class="mx-auto max-w-5xl">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-50">{{.Date}}</h1>
<p class="mt-1 text-sm text-slate-400">Footage overview</p>
</div>
<div class="flex rounded-md border border-slate-800 bg-slate-950 p-1">
<a href="/day/{{.Date}}/images" class="rounded px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Images ({{.ImageCount}})</a>
<a href="/day/{{.Date}}/videos" class="rounded px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Videos ({{.VideoCount}})</a>
</div>
</div>
</div>
{{end}}
{{end}}

58
internal/web/templates/images.html

@ -0,0 +1,58 @@
{{define "content"}}
{{with .Content}}
<div data-image-browser data-active-index="{{.ActiveIndex}}" class="flex h-full min-h-[calc(100vh-7rem)] flex-col gap-4">
<div class="flex items-center justify-between gap-3">
<div>
<h1 class="text-lg font-semibold text-slate-100">{{.Date}}</h1>
<p class="text-sm text-slate-400">Images</p>
</div>
{{if .Images}}
<div class="flex items-center gap-2">
<a href="/day/{{.Date}}/images?idx={{.PrevIndex}}" data-prev-index="{{.PrevIndex}}" class="rounded-md border border-slate-700 px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Prev</a>
<a href="/day/{{.Date}}/images?idx={{.NextIndex}}" data-next-index="{{.NextIndex}}" class="rounded-md border border-slate-700 px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Next</a>
</div>
{{end}}
</div>
{{if .Images}}
<div class="h-28 shrink-0 overflow-x-auto border-y border-slate-800 py-3">
<div class="flex h-full gap-3">
{{range .Images}}
<a href="/day/{{$.Content.Date}}/images?idx={{.Index}}" class="h-full w-40 shrink-0 overflow-hidden rounded-md border border-slate-800 bg-slate-950 {{if .Active}}ring-2 ring-indigo-400{{end}}">
<img src="{{.ThumbURL}}" alt="{{.Filename}}" class="h-full w-full object-cover">
</a>
{{end}}
</div>
</div>
<div class="relative min-h-0 flex-1 overflow-hidden bg-black">
<img src="{{.Current.RawURL}}" alt="{{.Current.Filename}}" class="h-full max-h-[calc(100vh-15rem)] min-h-[18rem] w-full object-contain">
<a href="/day/{{.Date}}/images?idx={{.PrevIndex}}" class="absolute left-3 top-1/2 -translate-y-1/2 rounded-md bg-slate-950/80 px-3 py-2 text-sm font-medium text-white hover:bg-slate-900">Prev</a>
<a href="/day/{{.Date}}/images?idx={{.NextIndex}}" class="absolute right-3 top-1/2 -translate-y-1/2 rounded-md bg-slate-950/80 px-3 py-2 text-sm font-medium text-white hover:bg-slate-900">Next</a>
</div>
<script>
(() => {
const root = document.querySelector("[data-image-browser]");
if (!root) return;
const active = Number(root.dataset.activeIndex || "0");
const prev = root.querySelector("[data-prev-index]")?.dataset.prevIndex || "0";
const next = root.querySelector("[data-next-index]")?.dataset.nextIndex || "0";
const go = (index) => {
const url = new URL(window.location.href);
url.searchParams.set("idx", index);
window.location.href = url.toString();
};
window.history.replaceState({ idx: active }, "", window.location.href);
window.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft") go(prev);
if (event.key === "ArrowRight") go(next);
});
})();
</script>
{{else}}
<div class="border border-slate-800 bg-slate-950 p-6 text-sm text-slate-400">No images for this day.</div>
{{end}}
</div>
{{end}}
{{end}}

25
internal/web/templates/login.html

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login - CamMonitor</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<main class="mx-auto flex min-h-screen max-w-sm items-center px-6">
<form method="post" action="/login" class="w-full space-y-4">
<h1 class="text-2xl font-semibold">CamMonitor</h1>
<label class="block space-y-1">
<span class="text-sm text-slate-300">Username</span>
<input name="username" autocomplete="username" class="w-full rounded bg-slate-900 px-3 py-2 text-slate-100 ring-1 ring-slate-700">
</label>
<label class="block space-y-1">
<span class="text-sm text-slate-300">Password</span>
<input name="password" type="password" autocomplete="current-password" class="w-full rounded bg-slate-900 px-3 py-2 text-slate-100 ring-1 ring-slate-700">
</label>
<button type="submit" class="w-full rounded bg-indigo-500 px-3 py-2 font-medium text-white">Sign in</button>
</form>
</main>
</body>
</html>

16
internal/web/templates/templates.go

@ -0,0 +1,16 @@
package templates
import "embed"
const (
Login = "login.html"
AdminUsers = "admin_users.html"
Base = "base.html"
Day = "day.html"
Images = "images.html"
)
// FS exposes file-backed HTML templates for handlers that render web pages.
//
//go:embed *.html
var FS embed.FS

1
testdata/footage/20260101/images/A26010112000001.jpg

@ -0,0 +1 @@
fixture

1
testdata/footage/20260101/images/A26010112050001.jpg

@ -0,0 +1 @@
fixture

1
testdata/footage/20260101/record/A260101_120000_120015.265

@ -0,0 +1 @@
fixture

1
testdata/footage/20260101/record/A260101_120500_120530.265

@ -0,0 +1 @@
fixture

1
testdata/footage/20260102/images/A26010213000001.jpg

@ -0,0 +1 @@
fixture
Loading…
Cancel
Save