AGENTS.md (view raw)
1# weimar — Agent Guide
2
3Single-binary media repository for home servers. Go backend with embedded SvelteKit SPA.
4
5## Essential Commands
6
7| Command | Purpose |
8|---------|---------|
9| `make build` | Full build: SvelteKit → embed → Go binary (output: `bin/weimar`) |
10| `make test` | `go test ./... -count=1` (disables cache) |
11| `make lint` | `golangci-lint run ./...` (v2 config) |
12| `make web-dev` | SvelteKit dev server (Vite) |
13| `make web-build` | SvelteKit production build only |
14| `make web-embed` | Build SvelteKit + copy into `cmd/weimar/web/build/` for embedding |
15| `make clean` | Remove all build artifacts |
16| `go vet ./...` | Passes clean |
17
18See full Makefile for details.
19
20## Build Flow
21
22```
23web/ (SvelteKit)
24 → bun run build → web/build/
25 → cp -r → cmd/weimar/web/build/
26 → go build with //go:embed web/build/* → single binary
27```
28
29The SvelteKit build is copied into the Go module so `embed.FS` can include it. **If you modify Svelte files, run `make web-embed` before building the Go binary**, or `make build` does both steps.
30
31## Code Organization
32
33```
34cmd/weimar/ # Cobra CLI entry point
35├── main.go # os.Exit on Execute() error
36├── root.go # Persistent flags, viper config init
37├── serve.go # Server command + //go:embed web/build/*
38├── users.go # Users subcommands (list/create/delete/password-reset)
39├── image.go # Image subcommands (delete)
40└── version.go # Version command
41
42internal/
43├── config/config.go # FromViper() reads typed Config struct from viper
44├── server/
45│ ├── server.go # HTTP server, route registration, SPA handler
46│ └── middleware.go # requireAuth wrapper, writeJSONError
47├── api/
48│ ├── api.go # API struct (dependency holder), writeJSON/writeError
49│ ├── auth.go # Login/Register/Logout handlers
50│ ├── upload.go # File upload + processing pipeline
51│ ├── images.go # List/Get image handlers
52│ ├── download.go # Serve original file
53│ ├── thumbnail.go # Serve thumbnail
54│ └── tags.go # Tag autocomplete handler
55├── db/
56│ ├── models.go # User, Image, Session, Tag structs
57│ ├── sqlite.go # DB open, close, migration system
58│ └── queries.go # All SQL CRUD operations
59├── auth/
60│ ├── session.go # SessionManager: login, authenticate, logout
61│ └── register.go # Register function with validation
62├── storage/
63│ ├── disk.go # Hash-partitioned file storage
64│ └── processing.go # MIME detection, EXIF strip, thumbnails
65└── config/config.go # Typed config struct (read from viper)
66
67web/ # SvelteKit SPA (adapter-static, CSR-only)
68├── src/
69│ ├── routes/
70│ │ ├── +layout.svelte # Nav bar, auth check, logout
71│ │ ├── +layout.js # ssr=false, prerender=false
72│ │ ├── +page.svelte # Home page
73│ │ ├── browse/+page.svelte # Grid with tag filter + pagination
74│ │ ├── login/+page.svelte # Login/Register form
75│ │ ├── upload/+page.svelte # File upload + tag autocomplete
76│ │ └── image/[id]/+page.svelte # Image detail + download
77│ └── lib/api.ts # Fetch wrapper, typed API functions
78```
79
80## Architecture
81
82### Backend (Go)
83- **Router**: Go 1.22+ `http.ServeMux` with method+pattern routing (e.g., `"GET /api/images/{id}"`)
84- **Config layering**: CLI flags > env vars (`WEIMAR_*`) > TOML file > defaults (set in `root.go:setDefaults()`)
85- **Database**: SQLite via `modernc.org/sqlite` (pure Go, no CGO). Single connection (`MaxOpenConns(1)`)
86- **Auth**: Cookie-based. Sessions stored in SQLite, 30-day sliding expiry, 64-char hex tokens
87- **Storage**: Hash-partitioned (`sha256[:2]/[2:4]/[4:6]/[:12].ext`), prevents directory overcrowding
88- **Error handling**: Sentinel errors in auth package, wrapped with `%w`, switch on sentinel in handlers
89
90### Frontend (SvelteKit)
91- **Svelte 5** with runes (`$state`, `$derived`, `$props`)
92- **CSR-only SPA** — `+layout.js` sets `ssr=false, prerender=false`
93- **Auth state**: Checked in layout by probing `/api/images?per_page=1` (no dedicated session endpoint)
94- **API layer**: `web/src/lib/api.ts` — thin fetch wrapper with `credentials: 'include'`, all paths prefixed `/api`
95- **No Node.js runtime in production** — embedded static files only
96
97### Request Flow
98```
99Browser → SvelteKit SPA → fetch('/api/...') → Go ServeMux
100 → requireAuth middleware (reads weimar_session cookie)
101 → API handler → DB/storage → JSON response → SPA renders
102```
103
104## Testing Patterns
105
106- **`:memory:` SQLite** for all tests — fast, isolated
107- **`t.TempDir()`** for temp storage — auto-cleaned
108- **`t.Cleanup(func() { db.Close() })`** for resource cleanup
109- **`t.Helper()`** on all setup/helper functions
110- **`setupTest(t)`** in `api_test.go` — creates DB, migrates, registers+logs in a test user, returns API + token
111- **`authenticatedRequest(t, ...)`** — creates `httptest.NewRequest` with session cookie + `CtxKeyUser` context
112- **`newTestDB(t)`** in `db_test.go` — opens `:memory:`, migrates
113- **`setupTestDB(t)`** in `auth_test.go` — same for auth package
114- Tests use table-driven style with descriptive test names
115- No external test dependencies; no test fixtures or golden files
116
117## Naming & Conventions
118
119| Pattern | Where | Example |
120|---------|-------|---------|
121| `scanXxx()` | `db/queries.go` | `scanUser`, `scanSession`, `scanImage` |
122| `writeJSON(w, status, v)` | `api/api.go` | All API handlers |
123| `writeError(w, status, msg)` | `api/api.go` | Error responses |
124| `UserFromContext(ctx)` | `api/api.go` | Extract authenticated user |
125| `now()` | `db/sqlite.go` | RFC 3339 UTC timestamp |
126| `scanTime(s)` | `db/sqlite.go` | Parse RFC 3339 string |
127| Sentinel errors | `auth/` | `ErrInvalidCredentials`, `ErrUsernameTaken` |
128| `CtxKeyUser` | `api/api.go` | Context key for authenticated user |
129| `itoa(n)` | `server/server.go` | Custom int→string (avoids strconv import) |
130
131## API Endpoints
132
133| Method | Path | Auth | Notes |
134|--------|------|------|-------|
135| `GET` | `/api/health` | No | `{"status":"ok"}` |
136| `POST` | `/api/auth/login` | No | Returns `weimar_session` cookie |
137| `POST` | `/api/auth/register` | No | Gated by `auth.allow_registration` config, auto-logs in |
138| `POST` | `/api/auth/logout` | Yes | Clears session |
139| `POST` | `/api/upload` | Yes | Multipart form: `file` + `tags` |
140| `GET` | `/api/images` | Yes | Query: `?tags=...&page=1&per_page=20` |
141| `GET` | `/api/images/{id}` | Yes | Full metadata |
142| `GET` | `/api/images/{id}/download` | Yes | Streams original file |
143| `GET` | `/api/images/{id}/thumbnail` | Yes | JPEG thumbnail (300px max) |
144| `GET` | `/api/tags/suggest?q=` | Yes | LIKE prefix match, ordered by usage |
145
146## Gotchas & Non-Obvious Patterns
147
1481. **Embed quirk**: `//go:embed web/build/*` in `serve.go` — gopls reports a stale `no matching files found` error until the SvelteKit build exists. The build compiles fine; this is a gopls caching issue.
149
1502. **Build order**: If you change Svelte files, you must run `make web-embed` before `go build`. Running `make build` handles this.
151
1523. **Session cookie**: Named `weimar_session` (constant in `auth/session.go`). `HttpOnly`, `SameSite=Lax`, no `Secure` flag (HTTP home server). Extended by 30 days on **every** authenticated request.
153
1544. **Auth check in SPA**: The layout pings `/api/images?per_page=1` to determine login status. There's no dedicated `/api/auth/me` endpoint.
155
1565. **Thumbnails**: Always JPEG quality 80. Videos depend on `ffmpeg` being on `$PATH` — if missing, `thumbnail_path` stays null and Svelte shows "No thumbnail".
157
1586. **MIME detection**: By magic bytes, never file extension. Triggered in `upload.go` before any processing.
159
1607. **EXIF stripping**: JPEG only. Skips APP1 segments without recompression. Other formats pass through unchanged.
161
1628. **Tag normalization**: `normalizeTag()` trims, lowercases, collapses internal whitespace. `" Funny Cat "` → `"funny cat"`.
163
1649. **Tag filter is AND**: Multiple `?tags=` query params require the image to have all specified tags (via `HAVING COUNT = N`).
165
16610. **Tag autocomplete**: From materialized `tags` table, not live query against `image_tags`. Updated on upload/tag change.
167
16811. **SQLite safety**: `MaxOpenConns(1)` — SQLite doesn't handle concurrent writes from multiple connections. This is intentional.
169
17012. **Password reset**: CLI command reads password from stdin (not arg), validates minimum length, hashes with bcrypt cost 10.
171
17213. **`app.html` linter warnings**: `%sveltekit.head%` and `%sveltekit.body%` cause false-positive HTML parser errors. These are correct SvelteKit template placeholders.
173
17414. **GoReleaser**: v2 config with UPX compression (level 9) for linux/darwin amd64/arm64. CGO_ENABLED=0. Windows disabled.
175
17615. **Golangci-lint**: v2 config with 12 specific linters. No `gofmt` or `revive` — relies on specific checks.
177
178## CLI Admin Commands
179
180```
181weimar serve --config weimar.toml --admin-email admin@example.com --admin-password s3cur3
182weimar users list
183weimar users create <username> <password> [--admin]
184weimar users delete <username>
185weimar users password-reset <username>
186weimar image delete <id>
187```
188
189All commands support `--config` flag (defaults to `./weimar.toml`) or `WEIMAR_CONFIG` env var.
190
191## Config Structure (TOML)
192
193```toml
194[server]
195host = "0.0.0.0"
196port = 8080
197
198[database]
199path = "./data/weimar.db"
200
201[storage]
202path = "./data/images"
203
204[upload]
205max_size_mb = 50
206rate_limit_per_minute = 0
207
208[auth]
209allow_registration = true
210```
211
212All fields have defaults (see `root.go:setDefaults()`). Config file is optional.