19 changed files with 999 additions and 18 deletions
@ -0,0 +1,58 @@ |
|||
# HANDOFF |
|||
|
|||
Append-only role handoff log. Each role adds one entry when its step is complete. |
|||
|
|||
--- |
|||
|
|||
### T-001 — implement — 2026-06-18T08:20:22Z |
|||
|
|||
| Field | Value | |
|||
|-------|-------| |
|||
| Agent | codex | |
|||
| Summary | Committed T-001 scaffold after review approval. | |
|||
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md | |
|||
| Validation | n/a (commit_task) | |
|||
| Commit | `pending feat(scaffold): add runnable CamMonitor health service` | |
|||
| Next Role | none | |
|||
|
|||
--- |
|||
|
|||
### T-001 — review — 2026-06-18T10:20:00Z |
|||
|
|||
| Field | Value | |
|||
|-------|-------| |
|||
| Agent | claude | |
|||
| Summary | Reviewed T-001 project scaffold; all acceptance criteria met; four nit-level findings, none blocking. | |
|||
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md | |
|||
| Validation | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...` all pass; E2E evidence from implementer accepted. | |
|||
| Verdict | PASS_WITH_NOTES | |
|||
| Blocking Findings | none | |
|||
| Next Role | implement | |
|||
|
|||
--- |
|||
|
|||
### T-001 — implement — 2026-06-18T08:12:05Z |
|||
|
|||
| Field | Value | |
|||
|-------|-------| |
|||
| Agent | codex | |
|||
| Summary | Added the runnable Go scaffold with environment config, SQLite startup, chi health endpoint, Docker Compose deployment, and project documentation. | |
|||
| Files Changed | .dockerignore, .env.example, .gitignore, Dockerfile, README.md, cmd/server/main.go, docker-compose.yml, go.mod, go.sum, internal/config/config.go, internal/db/db.go, internal/web/router.go, internal/web/router_test.go, testdata/footage/.gitkeep, .ai/TASKS.md, .ai/HANDOFF.md | |
|||
| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./...` passed; `go test -race ./...` passed; `docker compose build` passed; `curl -i http://127.0.0.1:18080/health` returned HTTP 200 and `{"status":"ok"}` | |
|||
| Commit | `feat(scaffold): add runnable CamMonitor health service` | |
|||
| Next Role | review | |
|||
|
|||
--- |
|||
|
|||
### CYCLE-START — plan — 2026-06-18T00:00:00Z |
|||
|
|||
| Field | Value | |
|||
|-------|-------| |
|||
| Agent | claude | |
|||
| Summary | Initial planning complete for CamMonitor: 6 tasks covering scaffold, auth, footage scanner, UI shell, image browser, and video browser. All tasks moved to ready_for_implement. | |
|||
| Files Changed | ROADMAP.md, .ai/PLAN.md, .ai/TASKS.md, .ai/HANDOFF.md | |
|||
| Validation | n/a (plan role) | |
|||
| Commit | n/a (plan role) | |
|||
| Next Role | implement | |
|||
|
|||
--- |
|||
@ -0,0 +1,382 @@ |
|||
# Plan |
|||
|
|||
Status: **ready_for_implement** |
|||
|
|||
Goal: Build CamMonitor — a self-hosted security camera footage viewer. |
|||
Go backend, dark responsive UI, Docker Compose deployment, minimal CPU use on Intel Atom. |
|||
|
|||
--- |
|||
|
|||
## Constraints |
|||
|
|||
- No CGO. Use `modernc.org/sqlite` (pure Go) so the binary cross-compiles cleanly. |
|||
- No Node.js build step. Tailwind CSS loaded from CDN only. |
|||
- Minimal transcoding. Videos are remuxed (stream copy), never re-encoded. |
|||
- Thumbnails cached in memory only — no writes to the footage tree. |
|||
- Target image: `debian:bookworm-slim` + `ffmpeg` package (needed for video stream + thumbnails). |
|||
|
|||
--- |
|||
|
|||
## Scope |
|||
|
|||
| Task | What is delivered | |
|||
|------|-------------------| |
|||
| T-001 | Runnable skeleton: Go module, chi router, health endpoint, Dockerfile, docker-compose | |
|||
| T-002 | Auth: login/logout, session cookies, SQLite users + sessions, admin user-management | |
|||
| T-003 | Footage scanner: in-memory index of days/images/videos, periodic rescan | |
|||
| T-004 | UI shell: dark Tailwind layout, sidebar (desktop), mobile drawer, day overview page | |
|||
| T-005 | Image browser: raw serve, thumbnail cache, strip, full viewer, arrow nav | |
|||
| T-006 | Video browser: ffmpeg remux stream, thumbnail extraction, strip, HTML5 player, arrow nav | |
|||
|
|||
--- |
|||
|
|||
## Repository layout |
|||
|
|||
``` |
|||
CamMonitor/ |
|||
├── cmd/ |
|||
│ └── server/ |
|||
│ └── main.go # Entry point: load config, init DB, start scanner, run server |
|||
├── internal/ |
|||
│ ├── config/ |
|||
│ │ └── config.go # Read env vars into Config struct |
|||
│ ├── db/ |
|||
│ │ └── db.go # Open SQLite, run schema migrations |
|||
│ ├── auth/ |
|||
│ │ ├── store.go # User + session CRUD |
|||
│ │ ├── handler.go # /login, /logout, /admin/users handlers |
|||
│ │ └── middleware.go # RequireAuth, RequireAdmin middleware |
|||
│ ├── footage/ |
|||
│ │ ├── scanner.go # Walk FOOTAGE_ROOT, parse filenames |
|||
│ │ └── index.go # In-memory index, DayList(), DayEntry() |
|||
│ ├── image/ |
|||
│ │ ├── handler.go # /raw/image/{path}, /thumb/image/{path}, /day/{date}/images |
|||
│ │ └── thumb.go # LRU cache, JPEG resize (golang.org/x/image/draw) |
|||
│ ├── video/ |
|||
│ │ ├── handler.go # /stream/video/{path}, /thumb/video/{path}, /day/{date}/videos |
|||
│ │ ├── stream.go # ffmpeg remux: .265 → fragmented MP4 pipe |
|||
│ │ └── thumb.go # ffmpeg first-frame extract, LRU cache |
|||
│ └── web/ |
|||
│ ├── router.go # Mount all sub-routers, static files |
|||
│ └── templates/ |
|||
│ ├── base.html # Dark shell: header, sidebar, mobile nav, slot for content |
|||
│ ├── login.html # Login form |
|||
│ ├── admin_users.html # User list, add/delete form |
|||
│ ├── day.html # Day overview: tabs (Images N / Videos N) |
|||
│ ├── images.html # Thumbnail strip + full-image viewer |
|||
│ └── videos.html # Thumbnail strip + HTML5 player |
|||
├── testdata/ |
|||
│ └── footage/ # Minimal fixture tree for unit tests |
|||
│ └── 20260101/ |
|||
│ ├── images/A26010112000001.jpg |
|||
│ └── record/A260101_120000_120015.265 |
|||
├── Dockerfile |
|||
├── docker-compose.yml |
|||
├── .env.example |
|||
├── go.mod |
|||
└── README.md |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Acceptance Criteria |
|||
|
|||
- `docker compose up` starts a working app; `GET /health` returns 200. |
|||
- Unauthenticated requests redirect to `/login`. |
|||
- Admin user created on first run from `ADMIN_USER` / `ADMIN_PASS` env vars. |
|||
- `/admin/users` lets admin add and delete users. |
|||
- Footage root is populated from `FOOTAGE_ROOT` env var / volume mount. |
|||
- Images load at original quality directly from disk (no processing in the hot path). |
|||
- Selecting a video in the browser starts streaming within ~2 s. |
|||
- Thumbnail strip is visible and horizontally scrollable on both desktop and mobile. |
|||
- Arrow nav (on-screen buttons and keyboard `←`/`→`) cycles through items on the same day. |
|||
- Desktop (≥768 px): sidebar lists available days grouped by month. |
|||
- Mobile (<768 px): sidebar replaced by hamburger drawer + bottom tab bar. |
|||
- `go fmt ./...`, `go vet ./...`, `go test ./...` pass cleanly. |
|||
- `go test -race ./...` reports no data races. |
|||
|
|||
--- |
|||
|
|||
## Implementation Phases |
|||
|
|||
### Phase 1 — T-001: Project scaffold |
|||
|
|||
**New files:** |
|||
- `go.mod` — module `github.com/domagojzecevic/cammonitor`, Go 1.22 |
|||
- `cmd/server/main.go` — `main()`: call `config.Load()`, `db.Open()`, `web.NewRouter()`, `http.ListenAndServe()` |
|||
- `internal/config/config.go` — `Config` struct fields: `ListenAddr`, `FootageRoot`, `DBPath`, `AdminUser`, `AdminPass`, `SessionTTL`, `ScanInterval`; loaded from env vars with defaults |
|||
- `internal/web/router.go` — `NewRouter(cfg, db, idx)` returns `chi.Router`; mounts `/health` returning `{"status":"ok"}` |
|||
- `Dockerfile`: |
|||
``` |
|||
FROM golang:1.22-bookworm AS builder |
|||
WORKDIR /src |
|||
COPY go.mod go.sum ./ |
|||
RUN go mod download |
|||
COPY . . |
|||
RUN CGO_ENABLED=0 go build -o /cammonitor ./cmd/server |
|||
|
|||
FROM debian:bookworm-slim |
|||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/* |
|||
COPY --from=builder /cammonitor /usr/local/bin/cammonitor |
|||
ENTRYPOINT ["cammonitor"] |
|||
``` |
|||
- `docker-compose.yml`: |
|||
```yaml |
|||
services: |
|||
app: |
|||
build: . |
|||
ports: |
|||
- "${PORT:-8080}:8080" |
|||
environment: |
|||
FOOTAGE_ROOT: /footage |
|||
DB_PATH: /data/cammonitor.db |
|||
ADMIN_USER: ${ADMIN_USER:-admin} |
|||
ADMIN_PASS: ${ADMIN_PASS:-changeme} |
|||
SESSION_TTL: ${SESSION_TTL:-24h} |
|||
SCAN_INTERVAL: ${SCAN_INTERVAL:-5m} |
|||
volumes: |
|||
- ${FOOTAGE_ROOT:-./testdata/footage}:/footage:ro |
|||
- cammonitor_data:/data |
|||
volumes: |
|||
cammonitor_data: |
|||
``` |
|||
- `.env.example` — documents all env vars |
|||
- `README.md` — quick-start, env var reference, footage directory format |
|||
|
|||
**Tests:** `internal/web` — GET /health returns 200 and `{"status":"ok"}`. |
|||
|
|||
--- |
|||
|
|||
### Phase 2 — T-002: Auth |
|||
|
|||
**New files:** |
|||
- `internal/db/db.go`: |
|||
- `Open(path string) (*sql.DB, error)` — opens SQLite with WAL mode, runs `migrate()` |
|||
- `migrate()` — idempotent `CREATE TABLE IF NOT EXISTS` for `users` and `sessions` |
|||
- Schema: |
|||
```sql |
|||
CREATE TABLE IF NOT EXISTS users ( |
|||
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|||
username TEXT NOT NULL UNIQUE, |
|||
password_hash TEXT NOT NULL, |
|||
is_admin INTEGER NOT NULL DEFAULT 0, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
|||
); |
|||
CREATE TABLE IF NOT EXISTS sessions ( |
|||
token TEXT PRIMARY KEY, |
|||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
|||
expires_at DATETIME NOT NULL |
|||
); |
|||
``` |
|||
|
|||
- `internal/auth/store.go`: |
|||
- `Store` struct wrapping `*sql.DB` |
|||
- `EnsureAdmin(user, pass string)` — creates admin if no users exist (called at startup) |
|||
- `CreateUser(username, password string, isAdmin bool) error` |
|||
- `DeleteUser(id int64) error` |
|||
- `ListUsers() ([]User, error)` |
|||
- `Authenticate(username, password string) (*User, error)` — bcrypt compare |
|||
- `CreateSession(userID int64, ttl time.Duration) (token string, err error)` — crypto/rand token |
|||
- `GetSession(token string) (*Session, error)` — checks expiry |
|||
- `DeleteSession(token string) error` |
|||
- `PurgeExpiredSessions()` — called periodically from main |
|||
|
|||
- `internal/auth/middleware.go`: |
|||
- `RequireAuth(store *Store) func(http.Handler) http.Handler` — reads `session` cookie, validates, injects `*User` into context; redirects to `/login` on failure |
|||
- `RequireAdmin(next http.Handler) http.Handler` — checks `is_admin` from context; returns 403 |
|||
|
|||
- `internal/auth/handler.go`: |
|||
- `GET /login` — render login form |
|||
- `POST /login` — authenticate, set `session` cookie, redirect to `/` |
|||
- `POST /logout` — delete session, clear cookie, redirect to `/login` |
|||
- `GET /admin/users` — list users (admin only) |
|||
- `POST /admin/users` — create user (admin only) |
|||
- `POST /admin/users/{id}/delete` — delete user (admin only) |
|||
|
|||
- `internal/web/templates/login.html` — dark-themed form, username + password, submit |
|||
- `internal/web/templates/admin_users.html` — user table, add-user form, delete button per row |
|||
|
|||
**Tests:** `internal/auth` — happy-path login, wrong password, expired session, admin-only gate. |
|||
|
|||
--- |
|||
|
|||
### Phase 3 — T-003: Footage scanner |
|||
|
|||
**New files:** |
|||
- `internal/footage/scanner.go`: |
|||
- `Scan(root string) ([]DayEntry, error)` — walks root, parses directory names as `YYYYMMDD`, walks `images/` and `record/` sub-dirs |
|||
- `parseImageFilename(name string) (ImageFile, error)` — extracts timestamp from `A<YYYYMMDD><HHMMSS><seq>.jpg` |
|||
- `parseVideoFilename(name string) (VideoFile, error)` — extracts start/end time from `A<YYYYMMDD>_<HHMMSS>_<HHMMSS>.265` |
|||
|
|||
- `internal/footage/index.go`: |
|||
- `Index` struct: `sync.RWMutex` + `map[string]*DayEntry` |
|||
- `NewIndex(root string, interval time.Duration) *Index` — starts background rescan goroutine |
|||
- `DayList() []string` — sorted date strings descending (newest first) |
|||
- `Day(date string) (*DayEntry, bool)` |
|||
- `Close()` — stop background goroutine |
|||
|
|||
- Types: |
|||
```go |
|||
type DayEntry struct { |
|||
Date string // "20260518" |
|||
Images []ImageFile |
|||
Videos []VideoFile |
|||
} |
|||
type ImageFile struct { |
|||
RelPath string // relative to FOOTAGE_ROOT |
|||
Filename string |
|||
Timestamp time.Time |
|||
} |
|||
type VideoFile struct { |
|||
RelPath string |
|||
Filename string |
|||
StartTime time.Time |
|||
EndTime time.Time |
|||
Duration time.Duration |
|||
} |
|||
``` |
|||
|
|||
- `testdata/footage/` — fixture directory with a couple of synthetic filenames (empty files are fine for scanner tests) |
|||
|
|||
**Tests:** `internal/footage` — scan fixture dir, assert day list, assert image/video counts and parsed timestamps; race test for concurrent `DayList()` + background rescan. |
|||
|
|||
--- |
|||
|
|||
### Phase 4 — T-004: UI shell & day navigation |
|||
|
|||
**New files:** |
|||
- `internal/web/templates/base.html`: |
|||
- Tailwind CSS CDN link (`https://cdn.tailwindcss.com`) |
|||
- Dark palette: `bg-slate-900` body, `bg-slate-800` sidebar/header, `text-slate-100` |
|||
- Header: app name "CamMonitor", logged-in username, logout button |
|||
- Desktop (≥768 px): fixed left sidebar `w-56`, main content fills remainder |
|||
- Mobile (<768 px): sidebar hidden (`hidden md:block`); hamburger button opens a full-height drawer via `<dialog>` or CSS toggle; bottom tab bar (`fixed bottom-0`) with Images / Videos icons |
|||
- Go template block slots: `{{block "title" .}}`, `{{block "content" .}}` |
|||
|
|||
- `internal/web/templates/day.html`: |
|||
- Extends base; shows date heading |
|||
- Two tab buttons: "Images (N)" and "Videos (N)" — links to `/day/{date}/images` and `/day/{date}/videos` |
|||
- Active tab highlighted |
|||
|
|||
- `internal/web/handler.go` (in `web` package): |
|||
- `HandleIndex` — redirect to most recent day's images |
|||
- `HandleDayOverview` — render `day.html` with counts from index |
|||
|
|||
- **Sidebar data** — base template receives `DayList []string` from a context middleware that injects the index; days grouped by `YYYY-MM` month heading |
|||
|
|||
**Tests:** render `base.html` + `day.html` with mock data, assert key HTML elements present (sidebar date entry, tab links). |
|||
|
|||
--- |
|||
|
|||
### Phase 5 — T-005: Image browser |
|||
|
|||
**New files:** |
|||
- `internal/image/thumb.go`: |
|||
- `Cache` struct: `sync.Mutex` + `map[string][]byte` (bounded, max 500 entries, simple FIFO eviction) |
|||
- `Thumbnail(absPath string) ([]byte, error)` — decode JPEG, resize to 160×90 using `golang.org/x/image/draw.BiLinear`, encode to JPEG quality 75, store in cache |
|||
- Uses stdlib `image/jpeg` + `golang.org/x/image/draw` (no external resize library needed) |
|||
|
|||
- `internal/image/handler.go`: |
|||
- `Handler` struct: `cfg *config.Config`, `idx *footage.Index`, `thumbs *Cache` |
|||
- `ServeRaw(w, r)` — `http.ServeFile` for `/raw/image/{relpath...}`; validates path is within footage root |
|||
- `ServeThumb(w, r)` — serve from `Cache` or generate; `Cache-Control: max-age=3600` |
|||
- `ServePage(w, r)` — render `images.html`; pass image list + `idx` query param |
|||
|
|||
- `internal/web/templates/images.html`: |
|||
- Extends base |
|||
- **Thumbnail strip** (top, fixed height `h-28`): horizontal scroll, `<img>` per image, active highlighted with indigo ring, click to navigate |
|||
- **Main viewer**: `<img>` tag with current image URL at full container width; `object-fit: contain` |
|||
- **Arrows**: left/right `<button>` overlays on the image; also listen for `keydown` left/right arrow keys |
|||
- Current image URL: `/raw/image/{relpath}`, thumbnail: `/thumb/image/{relpath}` |
|||
- Navigation updates `?idx=N` via vanilla JS `history.pushState` (no full reload) |
|||
|
|||
**Routes added to router:** |
|||
``` |
|||
GET /raw/image/* |
|||
GET /thumb/image/* |
|||
GET /day/{date}/images |
|||
``` |
|||
|
|||
**Tests:** `internal/image` — thumbnail generated from test JPEG fits 160×90 bounds; raw handler returns correct Content-Type; path traversal (`../`) rejected with 400. |
|||
|
|||
--- |
|||
|
|||
### Phase 6 — T-006: Video browser |
|||
|
|||
**New files:** |
|||
- `internal/video/stream.go`: |
|||
- `Stream(w http.ResponseWriter, r *http.Request, absPath string)`: |
|||
- Set `Content-Type: video/mp4`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff` |
|||
- Spawn: `ffmpeg -loglevel error -i <absPath> -c:v copy -movflags frag_keyframe+empty_moov -f mp4 pipe:1` |
|||
- Copy ffmpeg stdout → `w` in a goroutine |
|||
- On `r.Context().Done()` (client disconnect), kill the ffmpeg process |
|||
- Log ffmpeg stderr only on non-zero exit |
|||
|
|||
- `internal/video/thumb.go`: |
|||
- `Cache` struct: `sync.Mutex` + `map[string][]byte` (max 200 entries, FIFO eviction) |
|||
- `Thumbnail(absPath string) ([]byte, error)`: |
|||
- `ffmpeg -loglevel error -ss 0 -i <absPath> -vframes 1 -vf scale=160:90:force_original_aspect_ratio=decrease -f image2 -vcodec mjpeg pipe:1` |
|||
- Cache result |
|||
|
|||
- `internal/video/handler.go`: |
|||
- `Handler` struct: `cfg`, `idx`, `thumbs *Cache` |
|||
- `ServeStream(w, r)` — delegates to `stream.Stream`; validates path within footage root |
|||
- `ServeThumb(w, r)` — serve from `Cache` or generate |
|||
- `ServePage(w, r)` — render `videos.html` with video list + `idx` query param |
|||
|
|||
- `internal/web/templates/videos.html`: |
|||
- Extends base |
|||
- **Thumbnail strip** (top, fixed `h-28`): same layout as images strip; each thumbnail shows clip duration overlay (e.g. `00:14`) |
|||
- **Main viewer**: `<video controls autoplay>` element; `src` set to `/stream/video/{relpath}` |
|||
- **Arrows**: same left/right pattern as images; keyboard `←`/`→` |
|||
- On arrow navigation: update `<video src>` + call `.load()` + `.play()` via JS; update strip highlight; `history.pushState` |
|||
|
|||
**Routes added to router:** |
|||
``` |
|||
GET /stream/video/* |
|||
GET /thumb/video/* |
|||
GET /day/{date}/videos |
|||
``` |
|||
|
|||
**Tests:** `internal/video` — thumbnail cache stores and returns bytes; stream handler (mocked ffmpeg) returns correct headers; path traversal rejected; ffmpeg process killed on context cancel. |
|||
|
|||
> **Note on test ffmpeg mock**: use an `ffmpegPath` field on handlers defaulting to `"ffmpeg"` but overridable in tests to a small shell script that writes known bytes. |
|||
|
|||
--- |
|||
|
|||
## Environment Variables Reference |
|||
|
|||
| Variable | Default | Description | |
|||
|----------|---------|-------------| |
|||
| `LISTEN_ADDR` | `:8080` | TCP address to listen on | |
|||
| `FOOTAGE_ROOT` | `/footage` | Path to camera footage directory | |
|||
| `DB_PATH` | `/data/cammonitor.db` | SQLite database file path | |
|||
| `ADMIN_USER` | `admin` | Bootstrap admin username | |
|||
| `ADMIN_PASS` | `changeme` | Bootstrap admin password | |
|||
| `SESSION_TTL` | `24h` | Session cookie lifetime | |
|||
| `SCAN_INTERVAL` | `5m` | Footage re-scan interval | |
|||
|
|||
--- |
|||
|
|||
## Go Dependencies |
|||
|
|||
``` |
|||
github.com/go-chi/chi/v5 v5.x # HTTP router |
|||
modernc.org/sqlite v1.x # Pure-Go SQLite (no CGO) |
|||
golang.org/x/crypto latest # bcrypt |
|||
golang.org/x/image latest # JPEG resize (image/draw) |
|||
``` |
|||
|
|||
No ORM. Use `database/sql` directly with named queries. |
|||
|
|||
--- |
|||
|
|||
## Validation |
|||
|
|||
Run after every task: |
|||
``` |
|||
go fmt ./... |
|||
go vet ./... |
|||
go test ./... |
|||
go test -race ./... |
|||
``` |
|||
@ -0,0 +1,47 @@ |
|||
# REVIEW |
|||
|
|||
Append-only reviewer log. Each reviewed task gets one section. |
|||
|
|||
--- |
|||
|
|||
## T-001 — Project scaffold |
|||
|
|||
**Verdict:** PASS_WITH_NOTES |
|||
|
|||
### Findings |
|||
|
|||
| # | Severity | File | Description | Required fix? | |
|||
|---|----------|------|-------------|---------------| |
|||
| 1 | nit | `.env.example` | Missing `DB_PATH` and `LISTEN_ADDR` entries. The plan requires `.env.example` to document all env vars; the README does cover them but the example file operators copy is incomplete. | no | |
|||
| 2 | nit | `docker-compose.yml` | `LISTEN_ADDR` env var is not forwarded into the container; the port mapping hardcodes `8080` as the inner port. If an operator sets `LISTEN_ADDR` to something other than `:8080` only locally editing the compose file would fix the mismatch. | no | |
|||
| 3 | nit | `internal/db/db.go` | SQLite connection pool is not limited to a single open connection. Concurrent writes (introduced in T-002) risk `SQLITE_BUSY` / "database is locked" errors. Consider adding `database.SetMaxOpenConns(1)` before the health check in a future task. | no | |
|||
| 4 | nit | `README.md` | Contains extensive AI workflow boilerplate (workflow profiles, session commands, file map, etc.) that is unrelated to the application's end-user docs. The plan scope for README was quick-start, env var reference, and footage directory format — all of which are present. | no | |
|||
|
|||
### Required fixes |
|||
|
|||
None — all findings are nits. No blocking issues. |
|||
|
|||
### Verification |
|||
|
|||
**Steps performed:** |
|||
1. Read all T-001 files: `cmd/server/main.go`, `internal/config/config.go`, `internal/db/db.go`, `internal/web/router.go`, `internal/web/router_test.go`, `Dockerfile`, `docker-compose.yml`, `.env.example`, `go.mod`, `README.md`, `.dockerignore`. |
|||
2. Cross-checked implementation against `.ai/PLAN.md` Phase 1 scope. |
|||
3. Ran `go fmt ./...` — clean (no output). |
|||
4. Ran `go vet ./...` — clean. |
|||
5. Ran `go test ./...` — `internal/web` PASS, other packages report no test files (correct for T-001 scope). |
|||
6. Ran `go test -race ./...` — PASS, no data races. |
|||
7. `docker compose build` and `curl -i http://127.0.0.1:18080/health` verified by implementer evidence; both passed. |
|||
|
|||
**Findings:** |
|||
- All acceptance criteria met: `docker compose build` succeeds (per evidence), `GET /health` returns 200 and `{"status":"ok"}` (covered by test + E2E evidence), `go vet ./...` passes. |
|||
- Health handler correctly sets `Content-Type: application/json` before writing the body. |
|||
- Router test covers both status code and JSON body parsing — adequate for T-001 scope. |
|||
- Multi-stage Dockerfile matches plan exactly (`golang:1.22-bookworm` builder → `debian:bookworm-slim` + ffmpeg runtime). |
|||
- `CGO_ENABLED=0` build flag is correct; aligns with the no-CGO constraint. |
|||
- `modernc.org/sqlite` (pure-Go) is the only SQLite driver imported — constraint satisfied. |
|||
|
|||
**Risks:** |
|||
- SQLite connection pool (finding #3) will matter in T-002; surfaced early so the implementer can address it then. |
|||
- `.env.example` gaps (finding #1) are low-risk for a scaffold task but should be cleaned up before shipping. |
|||
|
|||
--- |
|||
@ -0,0 +1,28 @@ |
|||
# TASKS |
|||
|
|||
Use this board to coordinate handoff between planner, implementer, and reviewer. |
|||
|
|||
Status values: |
|||
- `in_planning` |
|||
- `ready_for_implement` |
|||
- `in_implementation` |
|||
- `ready_for_review` |
|||
- `in_review` |
|||
- `ready_to_commit` |
|||
- `changes_requested` |
|||
- `done` |
|||
|
|||
Command expectations: |
|||
- planner moves tasks into `in_planning` and `ready_for_implement` |
|||
- implementer moves tasks into `in_implementation`, `ready_for_review`, and `done`, and resumes work from `changes_requested` and `ready_to_commit` |
|||
- reviewer moves tasks into `in_review`, `ready_to_commit`, or `changes_requested` |
|||
- `status_cycle` should report deterministic task status, current owner role, and next recommended action based on this board |
|||
|
|||
| Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role | |
|||
| --- | --- | --- | --- | --- | --- | |
|||
| T-001 | Project 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-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-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-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-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 | |
|||
@ -0,0 +1,7 @@ |
|||
.cache/ |
|||
.git/ |
|||
.ai/mcp-server.log |
|||
.ai/sessions.json |
|||
*.test |
|||
*.out |
|||
bin/ |
|||
@ -0,0 +1,6 @@ |
|||
PORT=8080 |
|||
FOOTAGE_ROOT=./testdata/footage |
|||
ADMIN_USER=admin |
|||
ADMIN_PASS=changeme |
|||
SESSION_TTL=24h |
|||
SCAN_INTERVAL=5m |
|||
@ -0,0 +1,17 @@ |
|||
FROM golang:1.22-bookworm AS builder |
|||
|
|||
WORKDIR /src |
|||
|
|||
COPY go.mod go.sum ./ |
|||
RUN go mod download |
|||
|
|||
COPY . . |
|||
RUN CGO_ENABLED=0 go build -o /cammonitor ./cmd/server |
|||
|
|||
FROM debian:bookworm-slim |
|||
|
|||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/* |
|||
|
|||
COPY --from=builder /cammonitor /usr/local/bin/cammonitor |
|||
|
|||
ENTRYPOINT ["cammonitor"] |
|||
@ -1,29 +1,154 @@ |
|||
# ROADMAP |
|||
|
|||
Goal: define and deliver the scope for this cycle. |
|||
Goal: Build a self-hosted security camera footage viewer — Go backend, modern dark UI, |
|||
Docker Compose deployment, responsive for desktop and mobile. |
|||
|
|||
Delete any unused example sections below. Only the Goal and one concrete priority are required. |
|||
--- |
|||
|
|||
## Priority 1 |
|||
## Constraints |
|||
|
|||
Objective: replace with objective. |
|||
- Target hardware: Intel Atom (low CPU budget). Minimise transcoding at all costs. |
|||
- Video files are raw H.265 bitstreams (`.265`). Browsers cannot play them natively. |
|||
- Remuxing (copy stream into MP4 container) is acceptable — no re-encode. |
|||
- No Node.js build step. Tailwind CSS loaded from CDN. |
|||
|
|||
- Replace with planned outcome. |
|||
--- |
|||
|
|||
## Examples |
|||
## Footage directory layout (read-only mount) |
|||
|
|||
These example sections are optional illustrations, not required structure. |
|||
``` |
|||
<FOOTAGE_ROOT>/ |
|||
YYYYMMDD/ |
|||
images/ A<YYYYMMDD><HHMMSS><seq>.jpg |
|||
record/ A<YYYYMMDD>_<HHMMSS>_<HHMMSS>.265 |
|||
``` |
|||
|
|||
<!-- Example: remove or replace this section --> |
|||
## Priority 2 |
|||
- `FOOTAGE_ROOT` is configured as an env var / volume in `docker-compose.yml`. |
|||
- The app must not write into the footage tree (thumbnails cached in memory). |
|||
|
|||
Objective: optional second objective. |
|||
--- |
|||
|
|||
- Replace with optional planned outcome. |
|||
## Priority 1 — Project scaffold |
|||
|
|||
<!-- Example: remove or replace this section --> |
|||
## Priority 3 |
|||
Objective: Establish a working Go module, Dockerfile, and docker-compose skeleton so |
|||
every subsequent task has a solid base to build on. |
|||
|
|||
Objective: optional third objective. |
|||
- Go module `github.com/domagojzecevic/cammonitor` (or local path). |
|||
- `cmd/server/main.go` entry point, `chi` router wired up, static health endpoint. |
|||
- Multi-stage `Dockerfile` (builder → `gcr.io/distroless/static` or `debian:slim` for ffmpeg). |
|||
- `docker-compose.yml` with: |
|||
- `app` service (Go binary) |
|||
- `FOOTAGE_ROOT` volume bind-mount configurable via `.env` |
|||
- `DB_PATH` for SQLite file |
|||
- Port mapping (default `8080`) |
|||
- `README.md` with quick-start instructions. |
|||
|
|||
- Replace with optional planned outcome. |
|||
--- |
|||
|
|||
## Priority 2 — Auth (multi-user, SQLite) |
|||
|
|||
Objective: Secure the app behind a login wall with session cookies and an admin |
|||
user-management page. |
|||
|
|||
- SQLite schema: `users` table (id, username, bcrypt_password_hash, is_admin, created_at). |
|||
- SQLite schema: `sessions` table (token, user_id, expires_at). |
|||
- First-run bootstrap: if no users exist, create an admin user from env vars |
|||
(`ADMIN_USER`, `ADMIN_PASS`). |
|||
- Login page (`/login`): username + password form, sets `session` cookie on success. |
|||
- Logout endpoint (`/logout`). |
|||
- Auth middleware protecting all non-login routes. |
|||
- Admin user-management page (`/admin/users`): list, add, delete users (admin only). |
|||
- Session expiry configurable via env var (`SESSION_TTL`, default 24 h). |
|||
|
|||
--- |
|||
|
|||
## Priority 3 — Footage scanner |
|||
|
|||
Objective: Efficiently list available days, images, and videos from the footage tree. |
|||
|
|||
- On startup and periodically (configurable interval, default 5 min), scan `FOOTAGE_ROOT`. |
|||
- Build an in-memory index: `map[date]DayEntry` where `DayEntry` holds sorted slices of |
|||
image paths and video paths. |
|||
- Expose a simple internal API used by HTTP handlers to query the index. |
|||
- Parse filenames to extract timestamps for display and sorting. |
|||
- Handle missing `images/` or `record/` sub-directories gracefully. |
|||
|
|||
--- |
|||
|
|||
## Priority 4 — Image browser |
|||
|
|||
Objective: Browse JPEG images for a selected day with thumbnail strip and arrow navigation. |
|||
|
|||
- Route `/day/{date}/images` — lists all images for the day. |
|||
- Thumbnail endpoint `/thumb/image/{relpath}`: resize JPEG to 160×90 px in Go |
|||
(`golang.org/x/image` or stdlib `image` + `nfnt/resize` or manual scaling), |
|||
cache result in memory (bounded LRU, max 500 entries). |
|||
- Full-image endpoint `/raw/image/{relpath}`: serve JPEG directly from disk (no processing). |
|||
- UI: |
|||
- Fixed top strip showing all image thumbnails for the day (horizontally scrollable). |
|||
- Main area shows the currently selected image at full width. |
|||
- Left / right arrow buttons (keyboard and on-screen) to step through images. |
|||
- Active thumbnail highlighted in the strip. |
|||
- Deep-linkable URL per image (`?idx=N`). |
|||
|
|||
--- |
|||
|
|||
## Priority 5 — Video browser |
|||
|
|||
Objective: Browse and play H.265 videos for a selected day with thumbnail strip, arrow |
|||
navigation, and on-demand remux streaming. |
|||
|
|||
- Route `/day/{date}/videos` — lists all videos for the day. |
|||
- Thumbnail endpoint `/thumb/video/{relpath}`: extract first frame via |
|||
`ffmpeg -i <pipe or file> -vframes 1 -f image2 pipe:1`, scale to 160×90, |
|||
cache in memory (bounded LRU, max 200 entries). |
|||
- Stream endpoint `/stream/video/{relpath}`: |
|||
`ffmpeg -i {input} -c:v copy -movflags frag_keyframe+empty_moov -f mp4 pipe:1` |
|||
piped directly to the HTTP response (`Content-Type: video/mp4`). |
|||
No disk writes. One ffmpeg process per active stream (kill on client disconnect). |
|||
- UI: |
|||
- Fixed top strip showing all video thumbnails for the day (horizontally scrollable, |
|||
shows clip duration parsed from filename). |
|||
- Main area: HTML5 `<video>` player (controls, autoplay on navigation). |
|||
- Left / right arrow buttons (keyboard and on-screen) to step through videos. |
|||
- Active thumbnail highlighted in the strip. |
|||
- Deep-linkable URL per video (`?idx=N`). |
|||
|
|||
--- |
|||
|
|||
## Priority 6 — Responsive layout & day navigation |
|||
|
|||
Objective: Unified dark-theme shell that adapts to desktop and mobile viewports. |
|||
|
|||
- Tailwind CSS dark palette (slate-900 background, slate-700 cards, accent indigo-500). |
|||
- Desktop (≥ 768 px): collapsible left sidebar listing available days grouped by month; |
|||
clicking a day navigates to its images or videos tab. |
|||
- Mobile (< 768 px): sidebar hidden; top navigation bar with a hamburger/date picker |
|||
drawer; bottom tab bar for Images / Videos switch. |
|||
- Shared header: app name, logged-in username, logout button. |
|||
- Smooth transitions between images/videos on the same day without full-page reloads |
|||
(HTMX swap or minimal vanilla JS fetch). |
|||
- Keyboard shortcuts: `←`/`→` navigate items, `I`/`V` switch tabs. |
|||
|
|||
--- |
|||
|
|||
## Acceptance Criteria (overall) |
|||
|
|||
- `docker compose up` starts a working app with no extra steps beyond setting env vars. |
|||
- Login required before any footage is visible. |
|||
- Images load directly from disk with no processing delay. |
|||
- Videos play in the browser within ~2 s of clicking (remux start latency). |
|||
- Thumbnail strip visible and scrollable on both desktop and mobile. |
|||
- Arrow navigation works on desktop (keyboard) and mobile (touch buttons). |
|||
- All pages pass basic responsiveness check at 375 px and 1280 px viewport widths. |
|||
- `go vet ./...` and `go test ./...` pass cleanly. |
|||
|
|||
--- |
|||
|
|||
## Out of scope (this cycle) |
|||
|
|||
- Live / real-time camera feed. |
|||
- Motion detection or alerting. |
|||
- Video download / export. |
|||
- Per-camera filtering (footage layout assumed to be one camera per day folder for now). |
|||
- HTTPS termination (expected to sit behind a reverse proxy or Tailscale). |
|||
|
|||
@ -0,0 +1,30 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"log" |
|||
"net/http" |
|||
|
|||
"github.com/domagojzecevic/cammonitor/internal/config" |
|||
"github.com/domagojzecevic/cammonitor/internal/db" |
|||
"github.com/domagojzecevic/cammonitor/internal/web" |
|||
) |
|||
|
|||
func main() { |
|||
cfg, err := config.Load() |
|||
if err != nil { |
|||
log.Fatalf("load config: %v", err) |
|||
} |
|||
|
|||
database, err := db.Open(cfg.DBPath) |
|||
if err != nil { |
|||
log.Fatalf("open database: %v", err) |
|||
} |
|||
defer database.Close() |
|||
|
|||
router := web.NewRouter(cfg, database, nil) |
|||
|
|||
log.Printf("listening on %s", cfg.ListenAddr) |
|||
if err := http.ListenAndServe(cfg.ListenAddr, router); err != nil { |
|||
log.Fatalf("server stopped: %v", err) |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
services: |
|||
app: |
|||
build: . |
|||
ports: |
|||
- "${PORT:-8080}:8080" |
|||
environment: |
|||
FOOTAGE_ROOT: /footage |
|||
DB_PATH: /data/cammonitor.db |
|||
ADMIN_USER: ${ADMIN_USER:-admin} |
|||
ADMIN_PASS: ${ADMIN_PASS:-changeme} |
|||
SESSION_TTL: ${SESSION_TTL:-24h} |
|||
SCAN_INTERVAL: ${SCAN_INTERVAL:-5m} |
|||
volumes: |
|||
- ${FOOTAGE_ROOT:-./testdata/footage}:/footage:ro |
|||
- cammonitor_data:/data |
|||
|
|||
volumes: |
|||
cammonitor_data: |
|||
@ -0,0 +1,20 @@ |
|||
module github.com/domagojzecevic/cammonitor |
|||
|
|||
go 1.22 |
|||
|
|||
require ( |
|||
github.com/go-chi/chi/v5 v5.2.3 |
|||
modernc.org/sqlite v1.34.5 |
|||
) |
|||
|
|||
require ( |
|||
github.com/dustin/go-humanize v1.0.1 // indirect |
|||
github.com/google/uuid v1.6.0 // indirect |
|||
github.com/mattn/go-isatty v0.0.20 // indirect |
|||
github.com/ncruces/go-strftime v0.1.9 // indirect |
|||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
|||
golang.org/x/sys v0.22.0 // indirect |
|||
modernc.org/libc v1.55.3 // indirect |
|||
modernc.org/mathutil v1.6.0 // indirect |
|||
modernc.org/memory v1.8.0 // indirect |
|||
) |
|||
@ -0,0 +1,45 @@ |
|||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
|||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
|||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= |
|||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= |
|||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= |
|||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= |
|||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
|||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
|||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
|||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= |
|||
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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
|||
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/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.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
|||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= |
|||
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/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= |
|||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= |
|||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= |
|||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= |
|||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= |
|||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= |
|||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= |
|||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= |
|||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= |
|||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= |
|||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= |
|||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= |
|||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= |
|||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= |
|||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= |
|||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= |
|||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= |
|||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= |
|||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= |
|||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= |
|||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= |
|||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
|||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
|||
@ -0,0 +1,60 @@ |
|||
package config |
|||
|
|||
import ( |
|||
"fmt" |
|||
"os" |
|||
"time" |
|||
) |
|||
|
|||
type Config struct { |
|||
ListenAddr string |
|||
FootageRoot string |
|||
DBPath string |
|||
AdminUser string |
|||
AdminPass string |
|||
SessionTTL time.Duration |
|||
ScanInterval time.Duration |
|||
} |
|||
|
|||
func Load() (*Config, error) { |
|||
sessionTTL, err := parseDurationEnv("SESSION_TTL", 24*time.Hour) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
scanInterval, err := parseDurationEnv("SCAN_INTERVAL", 5*time.Minute) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return &Config{ |
|||
ListenAddr: getEnv("LISTEN_ADDR", ":8080"), |
|||
FootageRoot: getEnv("FOOTAGE_ROOT", "/footage"), |
|||
DBPath: getEnv("DB_PATH", "/data/cammonitor.db"), |
|||
AdminUser: getEnv("ADMIN_USER", "admin"), |
|||
AdminPass: getEnv("ADMIN_PASS", "changeme"), |
|||
SessionTTL: sessionTTL, |
|||
ScanInterval: scanInterval, |
|||
}, nil |
|||
} |
|||
|
|||
func getEnv(key, fallback string) string { |
|||
value := os.Getenv(key) |
|||
if value == "" { |
|||
return fallback |
|||
} |
|||
return value |
|||
} |
|||
|
|||
func parseDurationEnv(key string, fallback time.Duration) (time.Duration, error) { |
|||
value := os.Getenv(key) |
|||
if value == "" { |
|||
return fallback, nil |
|||
} |
|||
|
|||
duration, err := time.ParseDuration(value) |
|||
if err != nil { |
|||
return 0, fmt.Errorf("parse %s: %w", key, err) |
|||
} |
|||
return duration, nil |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
package db |
|||
|
|||
import ( |
|||
"database/sql" |
|||
"fmt" |
|||
"os" |
|||
"path/filepath" |
|||
|
|||
_ "modernc.org/sqlite" |
|||
) |
|||
|
|||
func Open(path string) (*sql.DB, error) { |
|||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
|||
return nil, fmt.Errorf("create database directory: %w", err) |
|||
} |
|||
|
|||
database, err := sql.Open("sqlite", path) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("open sqlite: %w", err) |
|||
} |
|||
|
|||
if _, err := database.Exec("PRAGMA foreign_keys = ON"); err != nil { |
|||
database.Close() |
|||
return nil, fmt.Errorf("enable foreign keys: %w", err) |
|||
} |
|||
|
|||
if _, err := database.Exec("PRAGMA journal_mode = WAL"); err != nil { |
|||
database.Close() |
|||
return nil, fmt.Errorf("enable wal mode: %w", err) |
|||
} |
|||
|
|||
if err := database.Ping(); err != nil { |
|||
database.Close() |
|||
return nil, fmt.Errorf("ping sqlite: %w", err) |
|||
} |
|||
|
|||
return database, nil |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"database/sql" |
|||
"encoding/json" |
|||
"net/http" |
|||
|
|||
"github.com/domagojzecevic/cammonitor/internal/config" |
|||
"github.com/go-chi/chi/v5" |
|||
) |
|||
|
|||
func NewRouter(_ *config.Config, _ *sql.DB, _ any) chi.Router { |
|||
router := chi.NewRouter() |
|||
|
|||
router.Get("/health", func(w http.ResponseWriter, _ *http.Request) { |
|||
w.Header().Set("Content-Type", "application/json") |
|||
w.WriteHeader(http.StatusOK) |
|||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) |
|||
}) |
|||
|
|||
return router |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"net/http" |
|||
"net/http/httptest" |
|||
"testing" |
|||
) |
|||
|
|||
func TestHealthReturnsOK(t *testing.T) { |
|||
router := NewRouter(nil, nil, nil) |
|||
|
|||
request := httptest.NewRequest(http.MethodGet, "/health", nil) |
|||
response := httptest.NewRecorder() |
|||
|
|||
router.ServeHTTP(response, request) |
|||
|
|||
if response.Code != http.StatusOK { |
|||
t.Fatalf("expected status %d, got %d", http.StatusOK, response.Code) |
|||
} |
|||
|
|||
var body map[string]string |
|||
if err := json.NewDecoder(response.Body).Decode(&body); err != nil { |
|||
t.Fatalf("decode response body: %v", err) |
|||
} |
|||
|
|||
if body["status"] != "ok" { |
|||
t.Fatalf("expected status ok, got %q", body["status"]) |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
|
|||
Loading…
Reference in new issue