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 (getMe), logout
71│ │ ├── +layout.js # ssr=false, prerender=false
72│ │ ├── +page.svelte # Home page
73│ │ ├── browse/+page.svelte # Gallery grid (6-col, tags, pagination, viewer)
74│ │ ├── login/+page.svelte # Login/Register form
75│ │ ├── upload/+page.svelte # File upload + tag autocomplete
76│ │ ├── settings/+page.svelte # Avatar upload, user info
77│ │ └── image/[id]/+page.svelte # Image detail + download
78│ └── lib/
79│ ├── api.ts # Fetch wrapper, typed API functions
80│ ├── Avatar.svelte # Avatar component (image or SVG fallback)
81│ └── ImageViewer.svelte # Full-screen overlay viewer
82```
83
84## Architecture
85
86### Backend (Go)
87- **Router**: Go 1.22+ `http.ServeMux` with method+pattern routing (e.g., `"GET /api/images/{id}"`)
88- **Config layering**: CLI flags > env vars (`WEIMAR_*`) > TOML file > defaults (set in `root.go:setDefaults()`)
89- **Database**: SQLite via `modernc.org/sqlite` (pure Go, no CGO). Single connection (`MaxOpenConns(1)`)
90- **Auth**: Cookie-based. Sessions stored in SQLite, 30-day sliding expiry, 64-char hex tokens
91- **Storage**: Hash-partitioned (`sha256[:2]/[2:4]/[4:6]/[:12].ext`), prevents directory overcrowding
92- **Error handling**: Sentinel errors in auth package, wrapped with `%w`, switch on sentinel in handlers
93
94### Frontend (SvelteKit)
95- **Svelte 5** with runes (`$state`, `$derived`, `$props`)
96- **CSR-only SPA** — `+layout.js` sets `ssr=false, prerender=false`
97- **Auth state**: Checked in layout by `GET /api/users/me` via `getMe()`
98- **API layer**: `web/src/lib/api.ts` — thin fetch wrapper with `credentials: 'include'`, all paths prefixed `/api`
99- **No Node.js runtime in production** — embedded static files only
100
101### Request Flow
102```
103Browser → SvelteKit SPA → fetch('/api/...') → Go ServeMux
104 → requireAuth middleware (reads weimar_session cookie)
105 → API handler → DB/storage → JSON response → SPA renders
106```
107
108## Testing Patterns
109
110- **`:memory:` SQLite** for all tests — fast, isolated
111- **`t.TempDir()`** for temp storage — auto-cleaned
112- **`t.Cleanup(func() { db.Close() })`** for resource cleanup
113- **`t.Helper()`** on all setup/helper functions
114- **`setupTest(t)`** in `api_test.go` — creates DB, migrates, registers+logs in a test user, returns API + token
115- **`authenticatedRequest(t, ...)`** — creates `httptest.NewRequest` with session cookie + `CtxKeyUser` context
116- **`newTestDB(t)`** in `db_test.go` — opens `:memory:`, migrates
117- **`setupTestDB(t)`** in `auth_test.go` — same for auth package
118- Tests use table-driven style with descriptive test names
119- No external test dependencies; no test fixtures or golden files
120- **Go nil slices marshal to JSON `null`**: Always use `make([]T, 0)` instead of `var s []T` for API-facing responses — the frontend can't call `.length` on `null`.
121- **`r.FormValue()` returns first value only**: When a form field appears multiple times (e.g., `tags=foo&tags=bar` from frontend `FormData.append`), `FormValue` returns only the first. Use `r.Form["tags"]` to get all values as `[]string`.
122
123## Naming & Conventions
124
125| Pattern | Where | Example |
126|---------|-------|---------|
127| `scanXxx()` | `db/queries.go` | `scanUser`, `scanSession`, `scanImage` |
128| `writeJSON(w, status, v)` | `api/api.go` | All API handlers |
129| `writeError(w, status, msg)` | `api/api.go` | Error responses |
130| `UserFromContext(ctx)` | `api/api.go` | Extract authenticated user |
131| `now()` | `db/sqlite.go` | RFC 3339 UTC timestamp |
132| `scanTime(s)` | `db/sqlite.go` | Parse RFC 3339 string |
133| Sentinel errors | `auth/` | `ErrInvalidCredentials`, `ErrUsernameTaken` |
134| `CtxKeyUser` | `api/api.go` | Context key for authenticated user |
135| `itoa(n)` | `server/server.go` | Custom int→string (avoids strconv import) |
136
137## API Endpoints
138
139| Method | Path | Auth | Notes |
140|--------|------|------|-------|
141| `GET` | `/api/health` | No | `{"status":"ok"}` |
142| `POST` | `/api/auth/login` | No | Returns `weimar_session` cookie |
143| `POST` | `/api/auth/register` | No | Gated by `auth.allow_registration` config, auto-logs in |
144| `POST` | `/api/auth/logout` | Yes | Clears session |
145| `POST` | `/api/upload` | Yes | Multipart form: `file` + `tags` |
146| `GET` | `/api/images` | No* | Query: `?tags=...&page=1&per_page=60` *public unless `auth.require_auth_for_browse=true` |
147| `GET` | `/api/images/{id}` | No* | Full metadata (incl. tags_detail, uploader info, is_owner) |
148| `GET` | `/api/images/{id}/download` | No* | Streams original file |
149| `GET` | `/api/images/{id}/thumbnail` | No* | JPEG thumbnail (300px max) |
150| `GET` | `/api/tags/suggest?q=` | Yes | LIKE prefix match, ordered by usage |
151| `POST` | `/api/images/{id}/tags` | Yes | Add tags (owner only) |
152| `DELETE` | `/api/images/{id}/tags` | Yes | Remove tags (owner only) |
153| `GET` | `/api/users/me` | Yes | Current user (username, avatar_url, is_admin) |
154| `POST` | `/api/users/me/avatar` | Yes | Upload avatar (128px JPEG) |
155| `GET` | `/api/users/{id}/avatar` | No | Serve avatar image |
156
157## Gotchas & Non-Obvious Patterns
158
1591. **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.
160
1612. **Build order**: If you change Svelte files, you must run `make web-embed` before `go build`. Running `make build` handles this.
162
1633. **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.
164
1654. **Auth check in SPA**: The layout calls `GET /api/users/me` via `getMe()` to determine login status and fetch user info.
166
1675. **Thumbnails**: Always JPEG quality 80. Videos depend on `ffmpeg` being on `$PATH` — if missing, `thumbnail_path` stays null and Svelte shows "No thumbnail".
168
1696. **MIME detection**: By magic bytes, never file extension. Triggered in `upload.go` before any processing.
170
1717. **EXIF stripping**: JPEG only. Skips APP1 segments without recompression. Other formats pass through unchanged.
172
1738. **Tag normalization**: `normalizeTag()` trims, lowercases, collapses internal whitespace. `" Funny Cat "` → `"funny cat"`.
174
1759. **Tag filter is AND**: Multiple `?tags=` query params require the image to have all specified tags (via `HAVING COUNT = N`).
176
17710. **Tag autocomplete**: From materialized `tags` table, not live query against `image_tags`. Updated on upload/tag change.
178
17911. **SQLite safety**: `MaxOpenConns(1)` — SQLite doesn't handle concurrent writes from multiple connections. This is intentional.
180
18112. **Password reset**: CLI command reads password from stdin (not arg), validates minimum length, hashes with bcrypt cost 10.
182
18313. **`app.html` linter warnings**: `%sveltekit.head%` and `%sveltekit.body%` cause false-positive HTML parser errors. These are correct SvelteKit template placeholders.
184
18514. **GoReleaser**: v2 config with UPX compression (level 9) for linux/darwin amd64/arm64. CGO_ENABLED=0. Windows disabled.
186
18715. **Golangci-lint**: v2 config with 12 specific linters. No `gofmt` or `revive` — relies on specific checks.
188
189## CLI Admin Commands
190
191```
192weimar serve --config weimar.toml --admin-email admin@example.com --admin-password s3cur3
193weimar users list
194weimar users create <username> <password> [--admin]
195weimar users delete <username>
196weimar users password-reset <username>
197weimar image delete <id>
198```
199
200All commands support `--config` flag (defaults to `./weimar.toml`) or `WEIMAR_CONFIG` env var.
201
202## Config Structure (TOML)
203
204```toml
205[server]
206host = "0.0.0.0"
207port = 8080
208# behind_proxy = true # set when behind Caddy/nginx (enables Secure cookies)
209# site_title = "My Site" # shown in nav and page title (default "weimar")
210
211[database]
212path = "./data/weimar.db"
213
214[storage]
215path = "./data/images"
216
217[upload]
218max_size_mb = 50
219rate_limit_per_minute = 0
220
221[auth]
222allow_registration = true
223require_auth_for_browse = false
224```
225
226All fields have defaults (see `root.go:setDefaults()`). Config file is optional.