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 |
# 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 --> |
- `FOOTAGE_ROOT` is configured as an env var / volume in `docker-compose.yml`. |
||||
## Priority 2 |
- 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 --> |
Objective: Establish a working Go module, Dockerfile, and docker-compose skeleton so |
||||
## Priority 3 |
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