commit 22ba34828438e63c2fb6f42fe8e979f95d453fb2 Author: Thomas Date: Sun Mar 8 14:44:50 2026 +0000 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84770d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.db* + +horchposten \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f755f2e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,208 @@ +# AGENTS.md — Horchposten + +Game analytics REST API backend written in Go with Echo v4 and SQLite. + +## Build / Run / Test + +### Build + +```bash +# Local (requires CGo + gcc for go-sqlite3) +CGO_ENABLED=1 go build -o horchposten . + +# Static binary (for containers / scratch base) +CGO_ENABLED=1 go build \ + -ldflags='-s -w -extldflags "-static"' \ + -tags 'netgo,osusergo' \ + -trimpath \ + -o horchposten . +``` + +### Container build + deploy to local k3s + +```bash +./build.sh +``` + +This runs `podman build`, exports the image, and imports it into k3s containerd. + +### Run + +Required environment variables: + +| Variable | Required | Default | Description | +|----------|----------|----------------|--------------------------| +| API_KEY | yes | — | Auth key for all routes | +| DB_PATH | no | `analytics.db` | SQLite database path | +| ADDR | no | `:8080` | Listen address | + +### Test + +```bash +# All tests +go test ./... + +# Single package +go test ./handlers/ + +# Single test function +go test ./handlers/ -run TestSessionStart + +# Verbose +go test -v ./... + +# With race detector (note: CGO_ENABLED=1 required) +CGO_ENABLED=1 go test -race ./... +``` + +No tests exist yet. When adding tests, place `*_test.go` files next to the code +they test (same package). Use the standard `testing` package. + +### Lint + +No linter is configured. Use `go vet` at minimum: + +```bash +go vet ./... +``` + +## Project Structure + +``` +main.go — entrypoint, routing, middleware wiring +db/ + db.go — database init, schema migrations +handlers/ + helpers.go — shared utilities (time, IP extraction, enums) + middleware.go — API key auth middleware + session_start.go — POST /api/analytics/session/start/ + session_end.go — POST /api/analytics/session/:session_id/end/ + leaderboard.go — GET /api/analytics/leaderboard/ + player_stats.go — GET /api/analytics/player/:client_id/ +models/ + models.go — all structs (domain models + request/response DTOs) +``` + +One handler per file. Shared helpers live in `handlers/helpers.go`. + +## Code Style + +### Formatting + +Standard `gofmt`. Tabs for indentation. No custom formatter config. + +### Imports + +Two groups separated by a blank line: + +1. Standard library (alphabetical) +2. Third-party and internal packages together (alphabetical) + +```go +import ( + "database/sql" + "net/http" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/tas/horchposten/models" +) +``` + +Internal packages (`github.com/tas/horchposten/...`) are NOT separated from +third-party — they sort alphabetically together in the second group. + +### Naming + +- **Files**: `snake_case.go` +- **Structs**: `PascalCase` nouns — `Player`, `GameSession`, `DeviceInfo` +- **Request/response types**: suffixed — `SessionStartRequest`, `PlayerStatsResponse`, `LeaderboardEntry` +- **Functions**: `PascalCase` exported, `camelCase` unexported +- **Variables**: short `camelCase` — `c` for echo.Context, `db` for database, `tx` for transaction, `req` for request body +- **JSON tags**: `snake_case` — `client_id`, `total_sessions`, `device_pixel_ratio` +- **Constants**: unexported `camelCase` — `timeFormat` + +### Handler Pattern + +Handlers are **closure functions** that accept dependencies and return `echo.HandlerFunc`: + +```go +// SessionStart handles POST /api/analytics/session/start/ +func SessionStart(db *sql.DB) echo.HandlerFunc { + return func(c echo.Context) error { + // ... + } +} +``` + +### HTTP Responses + +- Success: `c.JSON(http.StatusOK, echo.Map{...})` or `c.JSON(http.StatusOK, typedStruct)` +- Created: `http.StatusCreated` for POST that creates a resource +- Errors: always `c.JSON(statusCode, echo.Map{"error": "message"})` +- Use `echo.Map` for ad-hoc responses; use typed structs for complex payloads + +### Request Handling + +- Bind JSON body with `c.Bind(&req)` +- No struct validation tags — validate manually after binding +- Path params via `c.Param("name")`, query params via `c.QueryParam("name")` +- Validate UUIDs with `uuid.Parse()` +- All route paths have **trailing slashes** + +### Error Handling + +- **Handlers**: catch errors inline and return JSON error responses immediately. + Never propagate handler errors up. Use generic messages for internal errors + (`"database error"`, `"scan error"`). Use descriptive messages for validation + errors (`"client_id is required"`, `"invalid end_reason"`). + +```go +if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) +} +``` + +- **`sql.ErrNoRows`**: check with direct equality (`err == sql.ErrNoRows`), not `errors.Is` +- **`db` package**: wrap errors with `fmt.Errorf("context: %w", err)` +- **`main.go`**: use `log.Fatal` for startup-fatal conditions only + +### Database + +- SQLite with WAL mode and foreign keys: `path + "?_journal_mode=WAL&_foreign_keys=on"` +- Raw `*sql.DB` passed to handlers — no ORM, no repository layer +- All queries use `?` placeholders — never interpolate user input +- SQL keywords in UPPERCASE (`SELECT`, `FROM`, `WHERE`, etc.) +- Multi-line SQL in backtick raw strings + +**Write operations** use transactions: + +```go +tx, err := db.Begin() +if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) +} +defer tx.Rollback() + +// ... tx.Exec / tx.QueryRow ... + +if err := tx.Commit(); err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "failed to commit transaction"}) +} +``` + +**Read-only** handlers use `db.Query` / `db.QueryRow` directly. + +### Types + +- Use `sql.NullInt32`, `sql.NullFloat64`, `sql.NullString` for nullable DB columns +- Use pointer types (`*int`, `*float64`) for optional JSON fields in request/response structs +- Initialize slices with `make([]Type, 0)` (not `nil`) to ensure JSON serializes as `[]` not `null` + +### Comments + +- Exported functions/types: Go-doc comment starting with the symbol name +- Handler doc comments include HTTP method and route path: + `// Leaderboard handles GET /api/analytics/leaderboard/` +- Inline section comments within functions: short, end with period +- Separate model sections with `// --- Section Name ---` diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..9d1db3a --- /dev/null +++ b/Containerfile @@ -0,0 +1,29 @@ +# --- Build stage --- +FROM docker.io/library/golang:1.25-alpine AS build + +RUN apk add --no-cache gcc musl-dev + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=1 go build \ + -ldflags='-s -w -extldflags "-static"' \ + -tags 'netgo,osusergo' \ + -trimpath \ + -o /horchposten . + +# --- Runtime stage --- +FROM scratch + +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=build /horchposten /horchposten + +EXPOSE 8080 +VOLUME /data + +ENV DB_PATH=/data/analytics.db +ENV ADDR=:8080 + +ENTRYPOINT ["/horchposten"] diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..12e0357 --- /dev/null +++ b/build.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="localhost/horchposten:latest" + +echo "Building image: ${IMAGE}" +podman build -t "${IMAGE}" -f Containerfile . + +echo "Exporting image to OCI archive..." +podman save --format oci-archive -o horchposten.tar "${IMAGE}" + +echo "Importing image into k3s containerd..." +sudo k3s ctr images import horchposten.tar + +rm -f horchposten.tar + +echo "Done. Image available in k3s as: docker.io/library/horchposten:latest" +echo "Use imagePullPolicy: Never in your k8s manifests." diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..6efefe8 --- /dev/null +++ b/db/db.go @@ -0,0 +1,92 @@ +package db + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/mattn/go-sqlite3" +) + +// New opens a SQLite database at the given path and runs schema migrations. +func New(path string) (*sql.DB, error) { + db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("ping db: %w", err) + } + + if err := migrate(db); err != nil { + return nil, fmt.Errorf("migrate db: %w", err) + } + + log.Println("database ready:", path) + return db, nil +} + +func migrate(db *sql.DB) error { + schema := ` + CREATE TABLE IF NOT EXISTS players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL UNIQUE, + first_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + total_sessions INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_players_client_id ON players(client_id); + + CREATE TABLE IF NOT EXISTS player_ips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE, + ip_address TEXT NOT NULL, + first_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(player_id, ip_address) + ); + CREATE INDEX IF NOT EXISTS idx_player_ips_ip ON player_ips(ip_address); + + CREATE TABLE IF NOT EXISTS game_sessions ( + id TEXT PRIMARY KEY, + player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE, + score INTEGER NOT NULL DEFAULT 0, + level_reached INTEGER NOT NULL DEFAULT 1, + lives_used INTEGER NOT NULL DEFAULT 0, + duration_seconds INTEGER NOT NULL DEFAULT 0, + end_reason TEXT NOT NULL DEFAULT 'death', + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_game_sessions_score ON game_sessions(score DESC); + CREATE INDEX IF NOT EXISTS idx_game_sessions_player_ended ON game_sessions(player_id, ended_at DESC); + + CREATE TABLE IF NOT EXISTS device_infos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE REFERENCES game_sessions(id) ON DELETE CASCADE, + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + platform TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT '', + screen_width INTEGER, + screen_height INTEGER, + device_pixel_ratio REAL, + timezone TEXT NOT NULL DEFAULT '', + webgl_renderer TEXT NOT NULL DEFAULT '', + touch_support INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS high_scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + player_id INTEGER NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE, + score INTEGER NOT NULL, + session_id TEXT REFERENCES game_sessions(id) ON DELETE SET NULL, + achieved_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_high_scores_score ON high_scores(score DESC); + ` + + _, err := db.Exec(schema) + return err +} diff --git a/deployment.yaml b/deployment.yaml new file mode 100644 index 0000000..829edb2 --- /dev/null +++ b/deployment.yaml @@ -0,0 +1,54 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: horchposten-data + namespace: horchposten +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: horchposten + namespace: horchposten + labels: + app: horchposten +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: horchposten + template: + metadata: + labels: + app: horchposten + spec: + containers: + - name: horchposten + image: localhost/horchposten:latest + imagePullPolicy: Never + ports: + - containerPort: 8080 + env: + - name: API_KEY + value: "gDdLVZm0C0OZJdrBGLmufQsNIiHfiIj6" + volumeMounts: + - name: data + mountPath: /data + resources: + requests: + memory: 32Mi + cpu: 50m + limits: + memory: 64Mi + cpu: 75m + volumes: + - name: data + persistentVolumeClaim: + claimName: horchposten-data diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5ecfd11 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/tas/horchposten + +go 1.25.7 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/labstack/echo/v4 v4.15.1 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.34 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..810c266 --- /dev/null +++ b/go.sum @@ -0,0 +1,27 @@ +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/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs= +github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= diff --git a/handlers/admin.go b/handlers/admin.go new file mode 100644 index 0000000..932d200 --- /dev/null +++ b/handlers/admin.go @@ -0,0 +1,590 @@ +package handlers + +import ( + "crypto/subtle" + "database/sql" + "fmt" + "html/template" + "net/http" + "strconv" + "strings" + + "github.com/labstack/echo/v4" +) + +// AdminAuth returns middleware that validates the API key from either +// the X-API-Key header or the ?key= query parameter, making the admin +// UI accessible from a browser. +func AdminAuth(key string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + provided := c.Request().Header.Get("X-API-Key") + if provided == "" { + provided = c.QueryParam("key") + } + if provided == "" { + return c.HTML(http.StatusUnauthorized, unauthorizedHTML) + } + if subtle.ConstantTimeCompare([]byte(provided), []byte(key)) != 1 { + return c.HTML(http.StatusUnauthorized, unauthorizedHTML) + } + // Store key so templates can propagate it in links. + c.Set("api_key", provided) + return next(c) + } + } +} + +const unauthorizedHTML = ` +Unauthorized` + cssStyle + ` + +
+

Unauthorized

+

Provide your API key as a ?key= query parameter.

+
+` + +// knownTables is the ordered list of tables exposed in the admin UI. +var knownTables = []string{ + "players", + "player_ips", + "game_sessions", + "device_infos", + "high_scores", +} + +type columnInfo struct { + Name string + PK bool +} + +func getColumns(db *sql.DB, table string) ([]columnInfo, error) { + rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return nil, err + } + defer rows.Close() + + var cols []columnInfo + for rows.Next() { + var cid int + var name, typ string + var notNull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { + return nil, err + } + cols = append(cols, columnInfo{Name: name, PK: pk == 1}) + } + return cols, rows.Err() +} + +func getPKColumn(cols []columnInfo) string { + for _, c := range cols { + if c.PK { + return c.Name + } + } + return "" +} + +func isKnownTable(name string) bool { + for _, t := range knownTables { + if t == name { + return true + } + } + return false +} + +// AdminDashboard handles GET /admin/ — lists all tables with row counts. +func AdminDashboard(db *sql.DB) echo.HandlerFunc { + return func(c echo.Context) error { + type tableInfo struct { + Name string + Count int + } + var tables []tableInfo + for _, t := range knownTables { + var count int + row := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", t)) + if err := row.Scan(&count); err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + tables = append(tables, tableInfo{Name: t, Count: count}) + } + + data := map[string]interface{}{ + "Tables": tables, + } + return renderTemplate(c, dashboardTmpl, data) + } +} + +// AdminTable handles GET /admin/tables/:name/ — paginated table view. +func AdminTable(db *sql.DB) echo.HandlerFunc { + return func(c echo.Context) error { + table := c.Param("name") + if !isKnownTable(table) { + return c.JSON(http.StatusNotFound, echo.Map{"error": "table not found"}) + } + + cols, err := getColumns(db, table) + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + if len(cols) == 0 { + return c.JSON(http.StatusNotFound, echo.Map{"error": "table not found"}) + } + + // Pagination. + page, _ := strconv.Atoi(c.QueryParam("page")) + if page < 1 { + page = 1 + } + perPage := 50 + offset := (page - 1) * perPage + + // Total count. + var total int + if err := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&total); err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + + // Sort. + sortCol := c.QueryParam("sort") + sortDir := c.QueryParam("dir") + orderClause := "" + validSort := false + for _, col := range cols { + if col.Name == sortCol { + validSort = true + break + } + } + if validSort { + dir := "ASC" + if strings.EqualFold(sortDir, "desc") { + dir = "DESC" + } + orderClause = fmt.Sprintf(" ORDER BY %s %s", sortCol, dir) + } else { + // Default: order by primary key descending. + pk := getPKColumn(cols) + if pk != "" { + orderClause = fmt.Sprintf(" ORDER BY %s DESC", pk) + sortCol = pk + sortDir = "desc" + } + } + + // Search filter. + search := strings.TrimSpace(c.QueryParam("q")) + whereClause := "" + var args []interface{} + if search != "" { + var conditions []string + for _, col := range cols { + conditions = append(conditions, fmt.Sprintf("CAST(%s AS TEXT) LIKE ?", col.Name)) + args = append(args, "%"+search+"%") + } + whereClause = " WHERE " + strings.Join(conditions, " OR ") + + // Recount with filter. + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s%s", table, whereClause) + if err := db.QueryRow(countQuery, args...).Scan(&total); err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + } + + query := fmt.Sprintf("SELECT * FROM %s%s%s LIMIT ? OFFSET ?", table, whereClause, orderClause) + queryArgs := append(args, perPage, offset) + rows, err := db.Query(query, queryArgs...) + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + defer rows.Close() + + var rowData [][]string + colNames, _ := rows.Columns() + for rows.Next() { + vals := make([]interface{}, len(colNames)) + ptrs := make([]interface{}, len(colNames)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "scan error"}) + } + row := make([]string, len(colNames)) + for i, v := range vals { + if v == nil { + row[i] = "NULL" + } else { + row[i] = fmt.Sprintf("%v", v) + } + } + rowData = append(rowData, row) + } + + totalPages := (total + perPage - 1) / perPage + pk := getPKColumn(cols) + + // Find PK column index for linking. + pkIdx := -1 + for i, col := range cols { + if col.Name == pk { + pkIdx = i + break + } + } + + data := map[string]interface{}{ + "Table": table, + "Columns": colNames, + "Rows": rowData, + "Page": page, + "TotalPages": totalPages, + "Total": total, + "PerPage": perPage, + "Sort": sortCol, + "Dir": sortDir, + "Search": search, + "PKIndex": pkIdx, + "Tables": knownTables, + } + return renderTemplate(c, tableTmpl, data) + } +} + +// AdminRow handles GET /admin/tables/:name/:pk/ — single row detail. +func AdminRow(db *sql.DB) echo.HandlerFunc { + return func(c echo.Context) error { + table := c.Param("name") + pkVal := c.Param("pk") + if !isKnownTable(table) { + return c.JSON(http.StatusNotFound, echo.Map{"error": "table not found"}) + } + + cols, err := getColumns(db, table) + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + + pk := getPKColumn(cols) + if pk == "" { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "no primary key"}) + } + + query := fmt.Sprintf("SELECT * FROM %s WHERE %s = ?", table, pk) + rows, err := db.Query(query, pkVal) + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + defer rows.Close() + + colNames, _ := rows.Columns() + if !rows.Next() { + return c.JSON(http.StatusNotFound, echo.Map{"error": "row not found"}) + } + + vals := make([]interface{}, len(colNames)) + ptrs := make([]interface{}, len(colNames)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "scan error"}) + } + + type field struct { + Name string + Value string + } + var fields []field + for i, name := range colNames { + v := "NULL" + if vals[i] != nil { + v = fmt.Sprintf("%v", vals[i]) + } + fields = append(fields, field{Name: name, Value: v}) + } + + data := map[string]interface{}{ + "Table": table, + "PK": pkVal, + "Fields": fields, + "Tables": knownTables, + } + return renderTemplate(c, rowTmpl, data) + } +} + +func renderTemplate(c echo.Context, tmpl *template.Template, data interface{}) error { + // Inject the API key so templates can propagate it in links. + if m, ok := data.(map[string]interface{}); ok { + if key, exists := c.Get("api_key").(string); exists { + m["Key"] = key + } + } + c.Response().Header().Set("Content-Type", "text/html; charset=utf-8") + c.Response().WriteHeader(http.StatusOK) + return tmpl.Execute(c.Response().Writer, data) +} + +// --- HTML Templates --- + +var baseFuncs = template.FuncMap{ + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "toggleDir": func(dir string) string { + if strings.EqualFold(dir, "asc") { + return "desc" + } + return "asc" + }, + "seq": func(start, end int) []int { + var s []int + for i := start; i <= end; i++ { + s = append(s, i) + } + return s + }, + "min": func(a, b int) int { + if a < b { + return a + } + return b + }, + "max": func(a, b int) int { + if a > b { + return a + } + return b + }, + "eq": func(a, b interface{}) bool { + return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) + }, + "kp": func(key string) template.URL { + if key == "" { + return "" + } + return template.URL("key=" + key) + }, + "kpAmp": func(key string) template.URL { + if key == "" { + return "" + } + return template.URL("&key=" + key) + }, +} + +const cssStyle = ` + +` + +var dashboardTmpl = template.Must(template.New("dashboard").Funcs(baseFuncs).Parse(` + +Horchposten Admin` + cssStyle + ` + + +
+

Database

+
+ {{range .Tables}} + + {{end}} +
+
+ +`)) + +var tableTmpl = template.Must(template.New("table").Funcs(baseFuncs).Parse(` + +{{.Table}} — Horchposten Admin` + cssStyle + ` + + +
+

{{.Table}}

+ +
+
{{.Total}} rows total — page {{.Page}} of {{.TotalPages}}
+
+ + + + {{if .Search}}Clear{{end}} +
+
+ +
+ + + + {{range $i, $col := .Columns}} + + {{end}} + + + + {{if not .Rows}} + + {{end}} + {{range .Rows}} + + {{range $i, $val := .}} + {{if and (ge $.PKIndex 0) (eq $i $.PKIndex)}} + + {{else if eq $val "NULL"}} + + {{else}} + + {{end}} + {{end}} + + {{end}} + +
+ {{if eq $col $.Sort}} + + {{$col}} {{if eq $.Dir "asc"}}▲{{else}}▼{{end}} + + {{else}} + + {{$col}} + + {{end}} +
No rows found
{{$val}}NULL{{$val}}
+
+ + {{if gt .TotalPages 1}} + + {{end}} +
+ +`)) + +var rowTmpl = template.Must(template.New("row").Funcs(baseFuncs).Parse(` + +{{.Table}} #{{.PK}} — Horchposten Admin` + cssStyle + ` + + +
+

{{.Table}} › {{.PK}}

+
+ + + {{range .Fields}} + + + {{if eq .Value "NULL"}} + + {{else}} + + {{end}} + + {{end}} + +
{{.Name}}NULL{{.Value}}
+
+
+ +`)) diff --git a/handlers/helpers.go b/handlers/helpers.go new file mode 100644 index 0000000..53ba645 --- /dev/null +++ b/handlers/helpers.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "net" + "net/http" + "strings" + "time" +) + +const timeFormat = "2006-01-02T15:04:05.000Z" + +func now() string { + return time.Now().UTC().Format(timeFormat) +} + +func parseTime(s string) time.Time { + t, _ := time.Parse(timeFormat, s) + return t +} + +// getClientIP extracts the client IP, preferring X-Forwarded-For. +func getClientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + ip := strings.TrimSpace(parts[0]) + if ip != "" { + return ip + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +var validEndReasons = map[string]bool{ + "death": true, + "quit": true, + "timeout": true, + "completed": true, +} diff --git a/handlers/leaderboard.go b/handlers/leaderboard.go new file mode 100644 index 0000000..3feea0e --- /dev/null +++ b/handlers/leaderboard.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "database/sql" + "net/http" + "strconv" + + "github.com/labstack/echo/v4" + "github.com/tas/horchposten/models" +) + +// Leaderboard handles GET /api/analytics/leaderboard/ +func Leaderboard(db *sql.DB) echo.HandlerFunc { + return func(c echo.Context) error { + limit := 20 + if l := c.QueryParam("limit"); l != "" { + parsed, err := strconv.Atoi(l) + if err == nil && parsed > 0 { + limit = parsed + } + } + if limit > 100 { + limit = 100 + } + + rows, err := db.Query( + `SELECT p.client_id, h.score, h.achieved_at + FROM high_scores h + JOIN players p ON p.id = h.player_id + ORDER BY h.score DESC + LIMIT ?`, + limit, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + defer rows.Close() + + entries := make([]models.LeaderboardEntry, 0) + for rows.Next() { + var e models.LeaderboardEntry + var achievedAt string + if err := rows.Scan(&e.ClientID, &e.Score, &achievedAt); err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "scan error"}) + } + e.AchievedAt = parseTime(achievedAt) + entries = append(entries, e) + } + + return c.JSON(http.StatusOK, echo.Map{"leaderboard": entries}) + } +} diff --git a/handlers/middleware.go b/handlers/middleware.go new file mode 100644 index 0000000..f57d5c3 --- /dev/null +++ b/handlers/middleware.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "crypto/subtle" + "net/http" + + "github.com/labstack/echo/v4" +) + +// APIKeyAuth returns middleware that validates the X-API-Key header +// against the provided key using constant-time comparison. +func APIKeyAuth(key string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + provided := c.Request().Header.Get("X-API-Key") + if provided == "" { + return c.JSON(http.StatusUnauthorized, echo.Map{"error": "missing API key"}) + } + if subtle.ConstantTimeCompare([]byte(provided), []byte(key)) != 1 { + return c.JSON(http.StatusUnauthorized, echo.Map{"error": "invalid API key"}) + } + return next(c) + } + } +} diff --git a/handlers/player_stats.go b/handlers/player_stats.go new file mode 100644 index 0000000..17a7d5e --- /dev/null +++ b/handlers/player_stats.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "database/sql" + "net/http" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/tas/horchposten/models" +) + +// PlayerStats handles GET /api/analytics/player/:client_id/ +func PlayerStats(db *sql.DB) echo.HandlerFunc { + return func(c echo.Context) error { + clientID := c.Param("client_id") + if _, err := uuid.Parse(clientID); err != nil { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid client_id format"}) + } + + // Look up player. + var playerID int64 + var totalSessions int + var firstSeen string + err := db.QueryRow( + "SELECT id, total_sessions, first_seen FROM players WHERE client_id = ?", + clientID, + ).Scan(&playerID, &totalSessions, &firstSeen) + if err == sql.ErrNoRows { + return c.JSON(http.StatusNotFound, echo.Map{"error": "player not found"}) + } else if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + + // Get high score. + var highScore *int + var hs int + err = db.QueryRow( + "SELECT score FROM high_scores WHERE player_id = ?", playerID, + ).Scan(&hs) + if err == nil { + highScore = &hs + } + + // Get last 50 sessions. + rows, err := db.Query( + `SELECT id, score, level_reached, lives_used, duration_seconds, + end_reason, started_at, ended_at + FROM game_sessions + WHERE player_id = ? + ORDER BY ended_at DESC + LIMIT 50`, + playerID, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + defer rows.Close() + + sessions := make([]models.SessionSummary, 0) + for rows.Next() { + var s models.SessionSummary + var startedAt, endedAt string + if err := rows.Scan( + &s.SessionID, &s.Score, &s.LevelReached, &s.LivesUsed, + &s.DurationSeconds, &s.EndReason, &startedAt, &endedAt, + ); err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "scan error"}) + } + s.StartedAt = parseTime(startedAt) + s.EndedAt = parseTime(endedAt) + sessions = append(sessions, s) + } + + resp := models.PlayerStatsResponse{ + ClientID: clientID, + TotalSessions: totalSessions, + FirstSeen: parseTime(firstSeen), + HighScore: highScore, + Sessions: sessions, + } + + return c.JSON(http.StatusOK, resp) + } +} diff --git a/handlers/session_end.go b/handlers/session_end.go new file mode 100644 index 0000000..acf7416 --- /dev/null +++ b/handlers/session_end.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "database/sql" + "net/http" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/tas/horchposten/models" +) + +// SessionEnd handles POST /api/analytics/session/:session_id/end/ +func SessionEnd(db *sql.DB) echo.HandlerFunc { + return func(c echo.Context) error { + sessionID := c.Param("session_id") + if _, err := uuid.Parse(sessionID); err != nil { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid session_id format"}) + } + + var req models.SessionEndRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid JSON"}) + } + + // Apply defaults. + if req.LevelReached == 0 { + req.LevelReached = 1 + } + if req.EndReason == "" { + req.EndReason = "death" + } + if !validEndReasons[req.EndReason] { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid end_reason"}) + } + + tx, err := db.Begin() + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + defer tx.Rollback() + + // Look up session. + var playerID int64 + err = tx.QueryRow( + "SELECT player_id FROM game_sessions WHERE id = ?", sessionID, + ).Scan(&playerID) + if err == sql.ErrNoRows { + return c.JSON(http.StatusNotFound, echo.Map{"error": "session not found"}) + } else if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + + // Update session. + timestamp := now() + _, err = tx.Exec( + `UPDATE game_sessions SET score = ?, level_reached = ?, lives_used = ?, + duration_seconds = ?, end_reason = ?, ended_at = ? + WHERE id = ?`, + req.Score, req.LevelReached, req.LivesUsed, + req.DurationSeconds, req.EndReason, timestamp, + sessionID, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "failed to update session"}) + } + + // Increment total_sessions and update last_seen on player. + _, err = tx.Exec( + "UPDATE players SET total_sessions = total_sessions + 1, last_seen = ? WHERE id = ?", + timestamp, playerID, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "failed to update player"}) + } + + // Update high score. + newHighScore := false + var existingScore int + var highScoreID int64 + + err = tx.QueryRow( + "SELECT id, score FROM high_scores WHERE player_id = ?", playerID, + ).Scan(&highScoreID, &existingScore) + + if err == sql.ErrNoRows { + // First session for this player - create high score. + _, err = tx.Exec( + "INSERT INTO high_scores (player_id, score, session_id, achieved_at) VALUES (?, ?, ?, ?)", + playerID, req.Score, sessionID, timestamp, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "failed to create high score"}) + } + newHighScore = true + } else if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } else if req.Score >= existingScore { + // New score ties or beats existing high score. + _, err = tx.Exec( + "UPDATE high_scores SET score = ?, session_id = ?, achieved_at = ? WHERE id = ?", + req.Score, sessionID, timestamp, highScoreID, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "failed to update high score"}) + } + newHighScore = true + } + + if err := tx.Commit(); err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "failed to commit transaction"}) + } + + return c.JSON(http.StatusOK, echo.Map{ + "status": "ok", + "new_high_score": newHighScore, + }) + } +} diff --git a/handlers/session_start.go b/handlers/session_start.go new file mode 100644 index 0000000..4a880de --- /dev/null +++ b/handlers/session_start.go @@ -0,0 +1,133 @@ +package handlers + +import ( + "database/sql" + "net/http" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/tas/horchposten/models" +) + +// SessionStart handles POST /api/analytics/session/start/ +func SessionStart(db *sql.DB) echo.HandlerFunc { + return func(c echo.Context) error { + var req models.SessionStartRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid JSON"}) + } + + if req.ClientID == "" { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "client_id is required"}) + } + + if _, err := uuid.Parse(req.ClientID); err != nil { + return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid client_id format"}) + } + + tx, err := db.Begin() + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + defer tx.Rollback() + + // Get or create player. + timestamp := now() + var playerID int64 + + err = tx.QueryRow("SELECT id FROM players WHERE client_id = ?", req.ClientID).Scan(&playerID) + if err == sql.ErrNoRows { + res, err := tx.Exec( + "INSERT INTO players (client_id, first_seen, last_seen, total_sessions) VALUES (?, ?, ?, 0)", + req.ClientID, timestamp, timestamp, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "failed to create player"}) + } + playerID, _ = res.LastInsertId() + } else if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "database error"}) + } + + // Update or create PlayerIP. + clientIP := getClientIP(c.Request()) + var ipID int64 + err = tx.QueryRow( + "SELECT id FROM player_ips WHERE player_id = ? AND ip_address = ?", + playerID, clientIP, + ).Scan(&ipID) + if err == sql.ErrNoRows { + _, err = tx.Exec( + "INSERT INTO player_ips (player_id, ip_address, first_seen, last_seen) VALUES (?, ?, ?, ?)", + playerID, clientIP, timestamp, timestamp, + ) + } else if err == nil { + _, err = tx.Exec( + "UPDATE player_ips SET last_seen = ? WHERE id = ?", + timestamp, ipID, + ) + } + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "failed to update IP record"}) + } + + // Create GameSession. + sessionID := uuid.New().String() + _, err = tx.Exec( + `INSERT INTO game_sessions (id, player_id, score, level_reached, lives_used, + duration_seconds, end_reason, started_at, ended_at) + VALUES (?, ?, 0, 1, 0, 0, 'death', ?, ?)`, + sessionID, playerID, timestamp, timestamp, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "failed to create session"}) + } + + // Create DeviceInfo. + userAgent := c.Request().UserAgent() + if req.Device != nil { + d := req.Device + var sw, sh sql.NullInt32 + var dpr sql.NullFloat64 + if d.ScreenWidth != nil { + sw = sql.NullInt32{Int32: int32(*d.ScreenWidth), Valid: true} + } + if d.ScreenHeight != nil { + sh = sql.NullInt32{Int32: int32(*d.ScreenHeight), Valid: true} + } + if d.DevicePixelRatio != nil { + dpr = sql.NullFloat64{Float64: *d.DevicePixelRatio, Valid: true} + } + + _, err = tx.Exec( + `INSERT INTO device_infos (session_id, ip_address, user_agent, platform, language, + screen_width, screen_height, device_pixel_ratio, timezone, webgl_renderer, touch_support) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + sessionID, clientIP, userAgent, d.Platform, d.Language, + sw, sh, dpr, d.Timezone, d.WebGLRenderer, boolToInt(d.TouchSupport), + ) + } else { + _, err = tx.Exec( + `INSERT INTO device_infos (session_id, ip_address, user_agent) + VALUES (?, ?, ?)`, + sessionID, clientIP, userAgent, + ) + } + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "failed to create device info"}) + } + + if err := tx.Commit(); err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "failed to commit transaction"}) + } + + return c.JSON(http.StatusCreated, echo.Map{"session_id": sessionID}) + } +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..8f523e9 --- /dev/null +++ b/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "log" + "os" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/tas/horchposten/db" + "github.com/tas/horchposten/handlers" +) + +func main() { + dbPath := "analytics.db" + if p := os.Getenv("DB_PATH"); p != "" { + dbPath = p + } + + addr := ":8080" + if a := os.Getenv("ADDR"); a != "" { + addr = a + } + + apiKey := os.Getenv("API_KEY") + if apiKey == "" { + log.Fatal("API_KEY environment variable is required") + } + + database, err := db.New(dbPath) + if err != nil { + log.Fatal(err) + } + defer database.Close() + + e := echo.New() + + // Middleware. + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{echo.GET, echo.POST, echo.OPTIONS}, + AllowHeaders: []string{echo.HeaderContentType, "X-API-Key"}, + })) + + // Routes — all analytics endpoints require API key. + api := e.Group("/api/analytics", handlers.APIKeyAuth(apiKey)) + api.POST("/session/start/", handlers.SessionStart(database)) + api.POST("/session/:session_id/end/", handlers.SessionEnd(database)) + api.GET("/leaderboard/", handlers.Leaderboard(database)) + api.GET("/player/:client_id/", handlers.PlayerStats(database)) + + // Admin UI — browser-friendly auth via ?key= query param or X-API-Key header. + admin := e.Group("/admin", handlers.AdminAuth(apiKey)) + admin.GET("/", handlers.AdminDashboard(database)) + admin.GET("/tables/:name/", handlers.AdminTable(database)) + admin.GET("/tables/:name/:pk/", handlers.AdminRow(database)) + + log.Printf("starting server on %s", addr) + e.Logger.Fatal(e.Start(addr)) +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..5d5d868 --- /dev/null +++ b/models/models.go @@ -0,0 +1,119 @@ +package models + +import ( + "database/sql" + "time" +) + +// Player represents an anonymous player identified by a client-generated UUID. +type Player struct { + ID int64 `json:"-"` + ClientID string `json:"client_id"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + TotalSessions int `json:"total_sessions"` +} + +// PlayerIP tracks distinct IP addresses a player has connected from. +type PlayerIP struct { + ID int64 `json:"-"` + PlayerID int64 `json:"-"` + IPAddress string `json:"ip_address"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` +} + +// GameSession represents a single play session. +type GameSession struct { + ID string `json:"session_id"` + PlayerID int64 `json:"-"` + Score int `json:"score"` + LevelReached int `json:"level_reached"` + LivesUsed int `json:"lives_used"` + DurationSeconds int `json:"duration_seconds"` + EndReason string `json:"end_reason"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` +} + +// DeviceInfo holds browser/device fingerprint data captured per session. +type DeviceInfo struct { + ID int64 `json:"-"` + SessionID string `json:"-"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + Platform string `json:"platform"` + Language string `json:"language"` + ScreenWidth sql.NullInt32 `json:"screen_width"` + ScreenHeight sql.NullInt32 `json:"screen_height"` + DevicePixelRatio sql.NullFloat64 `json:"device_pixel_ratio"` + Timezone string `json:"timezone"` + WebGLRenderer string `json:"webgl_renderer"` + TouchSupport bool `json:"touch_support"` +} + +// HighScore is a denormalized personal-best record, one per player. +type HighScore struct { + ID int64 `json:"-"` + PlayerID int64 `json:"-"` + Score int `json:"score"` + SessionID sql.NullString `json:"-"` + AchievedAt time.Time `json:"achieved_at"` +} + +// --- Request/Response types --- + +// DeviceInfoRequest is the optional device payload in session start. +type DeviceInfoRequest struct { + Platform string `json:"platform"` + Language string `json:"language"` + ScreenWidth *int `json:"screen_width"` + ScreenHeight *int `json:"screen_height"` + DevicePixelRatio *float64 `json:"device_pixel_ratio"` + Timezone string `json:"timezone"` + WebGLRenderer string `json:"webgl_renderer"` + TouchSupport bool `json:"touch_support"` +} + +// SessionStartRequest is the JSON body for POST /session/start/. +type SessionStartRequest struct { + ClientID string `json:"client_id"` + Device *DeviceInfoRequest `json:"device"` +} + +// SessionEndRequest is the JSON body for POST /session/:id/end/. +type SessionEndRequest struct { + Score int `json:"score"` + LevelReached int `json:"level_reached"` + LivesUsed int `json:"lives_used"` + DurationSeconds int `json:"duration_seconds"` + EndReason string `json:"end_reason"` +} + +// LeaderboardEntry is a single row in the leaderboard response. +type LeaderboardEntry struct { + ClientID string `json:"client_id"` + Score int `json:"score"` + AchievedAt time.Time `json:"achieved_at"` +} + +// SessionSummary is a session entry in the player stats response. +type SessionSummary struct { + SessionID string `json:"session_id"` + Score int `json:"score"` + LevelReached int `json:"level_reached"` + LivesUsed int `json:"lives_used"` + DurationSeconds int `json:"duration_seconds"` + EndReason string `json:"end_reason"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` +} + +// PlayerStatsResponse is the response for GET /player/:client_id/. +type PlayerStatsResponse struct { + ClientID string `json:"client_id"` + TotalSessions int `json:"total_sessions"` + FirstSeen time.Time `json:"first_seen"` + HighScore *int `json:"high_score"` + Sessions []SessionSummary `json:"sessions"` +}