This commit is contained in:
Thomas
2026-03-08 14:44:50 +00:00
commit 22ba348284
17 changed files with 1674 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.db*
horchposten

208
AGENTS.md Normal file
View File

@@ -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 ---`

29
Containerfile Normal file
View File

@@ -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"]

18
build.sh Executable file
View File

@@ -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."

92
db/db.go Normal file
View File

@@ -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
}

54
deployment.yaml Normal file
View File

@@ -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

19
go.mod Normal file
View File

@@ -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
)

27
go.sum Normal file
View File

@@ -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=

590
handlers/admin.go Normal file
View File

@@ -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 = `<!DOCTYPE html>
<html><head><title>Unauthorized</title>` + cssStyle + `</head>
<body>
<div class="container" style="margin-top:80px;text-align:center">
<h1>Unauthorized</h1>
<p style="margin-top:12px;color:#666">Provide your API key as a <code>?key=</code> query parameter.</p>
</div>
</body></html>`
// 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, &notNull, &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 = `
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
h1 { margin-bottom: 20px; font-size: 24px; color: #111; }
h2 { margin-bottom: 16px; font-size: 20px; color: #111; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
/* Navigation */
.nav { background: #1a1a2e; padding: 12px 20px; margin-bottom: 24px; }
.nav a { color: #e0e0e0; margin-right: 16px; font-size: 14px; }
.nav a:hover { color: #fff; text-decoration: none; }
.nav a.active { color: #fff; font-weight: 600; }
.nav .brand { color: #fff; font-weight: 700; font-size: 16px; margin-right: 32px; }
/* Dashboard cards */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
.card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); transition: box-shadow 0.2s; }
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.card h3 { font-size: 16px; margin-bottom: 8px; }
.card .count { font-size: 28px; font-weight: 700; color: #0066cc; }
.card a { display: block; color: inherit; }
.card a:hover { text-decoration: none; }
/* Table */
.table-wrap { background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { background: #f8f9fa; text-align: left; padding: 10px 12px; border-bottom: 2px solid #dee2e6; white-space: nowrap; position: sticky; top: 0; }
th a { color: #333; }
th .sort-arrow { font-size: 11px; margin-left: 4px; color: #999; }
th .sort-arrow.active { color: #0066cc; }
td { padding: 8px 12px; border-bottom: 1px solid #eee; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
tr:hover td { background: #f8f9fa; }
td.null { color: #999; font-style: italic; }
/* Toolbar */
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }
.toolbar .info { font-size: 14px; color: #666; }
.search-form { display: flex; gap: 8px; }
.search-form input { padding: 6px 12px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; width: 260px; }
.search-form button { padding: 6px 16px; background: #0066cc; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
.search-form button:hover { background: #0055aa; }
/* Pagination */
.pagination { display: flex; justify-content: center; gap: 4px; margin-top: 16px; }
.pagination a, .pagination span { padding: 6px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.pagination a:hover { background: #e9ecef; text-decoration: none; }
.pagination .current { background: #0066cc; color: #fff; border-color: #0066cc; }
.pagination .disabled { color: #ccc; pointer-events: none; }
/* Detail view */
.detail-table { width: 100%; }
.detail-table th { width: 200px; background: #f8f9fa; font-weight: 600; vertical-align: top; }
.detail-table td { word-break: break-all; white-space: normal; }
</style>
`
var dashboardTmpl = template.Must(template.New("dashboard").Funcs(baseFuncs).Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Horchposten Admin</title>` + cssStyle + `</head>
<body>
<div class="nav">
<span class="brand">Horchposten</span>
<a href="/admin/?{{kp .Key}}" class="active">Dashboard</a>
</div>
<div class="container">
<h1>Database</h1>
<div class="cards">
{{range .Tables}}
<div class="card">
<a href="/admin/tables/{{.Name}}/?{{kp $.Key}}">
<h3>{{.Name}}</h3>
<div class="count">{{.Count}}</div>
<div style="font-size:13px;color:#666;margin-top:4px">rows</div>
</a>
</div>
{{end}}
</div>
</div>
</body>
</html>`))
var tableTmpl = template.Must(template.New("table").Funcs(baseFuncs).Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{{.Table}} — Horchposten Admin</title>` + cssStyle + `</head>
<body>
<div class="nav">
<span class="brand">Horchposten</span>
<a href="/admin/?{{kp .Key}}">Dashboard</a>
{{range .Tables}}<a href="/admin/tables/{{.}}/?{{kp $.Key}}"{{if eq . $.Table}} class="active"{{end}}>{{.}}</a>{{end}}
</div>
<div class="container">
<h2>{{.Table}}</h2>
<div class="toolbar">
<div class="info">{{.Total}} rows total — page {{.Page}} of {{.TotalPages}}</div>
<form class="search-form" method="get" action="/admin/tables/{{.Table}}/">
<input type="hidden" name="key" value="{{.Key}}">
<input type="text" name="q" placeholder="Search all columns..." value="{{.Search}}">
<button type="submit">Search</button>
{{if .Search}}<a href="/admin/tables/{{.Table}}/?{{kp .Key}}" style="padding:6px 12px;font-size:14px">Clear</a>{{end}}
</form>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
{{range $i, $col := .Columns}}
<th>
{{if eq $col $.Sort}}
<a href="?sort={{$col}}&dir={{toggleDir $.Dir}}{{if $.Search}}&q={{$.Search}}{{end}}{{kpAmp $.Key}}">
{{$col}} <span class="sort-arrow active">{{if eq $.Dir "asc"}}&#9650;{{else}}&#9660;{{end}}</span>
</a>
{{else}}
<a href="?sort={{$col}}&dir=asc{{if $.Search}}&q={{$.Search}}{{end}}{{kpAmp $.Key}}">
{{$col}} <span class="sort-arrow">&#9650;</span>
</a>
{{end}}
</th>
{{end}}
</tr>
</thead>
<tbody>
{{if not .Rows}}
<tr><td colspan="{{len .Columns}}" style="text-align:center;padding:24px;color:#999">No rows found</td></tr>
{{end}}
{{range .Rows}}
<tr>
{{range $i, $val := .}}
{{if and (ge $.PKIndex 0) (eq $i $.PKIndex)}}
<td><a href="/admin/tables/{{$.Table}}/{{$val}}/?{{kp $.Key}}">{{$val}}</a></td>
{{else if eq $val "NULL"}}
<td class="null">NULL</td>
{{else}}
<td>{{$val}}</td>
{{end}}
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .Page 1}}
<a href="?page={{sub .Page 1}}&sort={{.Sort}}&dir={{.Dir}}{{if .Search}}&q={{.Search}}{{end}}{{kpAmp .Key}}">&#8592; Prev</a>
{{else}}
<span class="disabled">&#8592; Prev</span>
{{end}}
{{$start := max 1 (sub .Page 2)}}
{{$end := min .TotalPages (add .Page 2)}}
{{if gt $start 1}}<a href="?page=1&sort={{.Sort}}&dir={{.Dir}}{{if .Search}}&q={{.Search}}{{end}}{{kpAmp .Key}}">1</a>{{if gt $start 2}}<span>...</span>{{end}}{{end}}
{{range seq $start $end}}
{{if eq . $.Page}}
<span class="current">{{.}}</span>
{{else}}
<a href="?page={{.}}&sort={{$.Sort}}&dir={{$.Dir}}{{if $.Search}}&q={{$.Search}}{{end}}{{kpAmp $.Key}}">{{.}}</a>
{{end}}
{{end}}
{{if lt $end $.TotalPages}}{{if lt (add $end 1) $.TotalPages}}<span>...</span>{{end}}<a href="?page={{$.TotalPages}}&sort={{.Sort}}&dir={{.Dir}}{{if .Search}}&q={{.Search}}{{end}}{{kpAmp .Key}}">{{$.TotalPages}}</a>{{end}}
{{if lt .Page .TotalPages}}
<a href="?page={{add .Page 1}}&sort={{.Sort}}&dir={{.Dir}}{{if .Search}}&q={{.Search}}{{end}}{{kpAmp .Key}}">Next &#8594;</a>
{{else}}
<span class="disabled">Next &#8594;</span>
{{end}}
</div>
{{end}}
</div>
</body>
</html>`))
var rowTmpl = template.Must(template.New("row").Funcs(baseFuncs).Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{{.Table}} #{{.PK}} — Horchposten Admin</title>` + cssStyle + `</head>
<body>
<div class="nav">
<span class="brand">Horchposten</span>
<a href="/admin/?{{kp .Key}}">Dashboard</a>
{{range .Tables}}<a href="/admin/tables/{{.}}/?{{kp $.Key}}"{{if eq . $.Table}} class="active"{{end}}>{{.}}</a>{{end}}
</div>
<div class="container">
<h2><a href="/admin/tables/{{.Table}}/?{{kp .Key}}">{{.Table}}</a> &rsaquo; {{.PK}}</h2>
<div class="table-wrap">
<table class="detail-table">
<tbody>
{{range .Fields}}
<tr>
<th>{{.Name}}</th>
{{if eq .Value "NULL"}}
<td class="null">NULL</td>
{{else}}
<td>{{.Value}}</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</body>
</html>`))

42
handlers/helpers.go Normal file
View File

@@ -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,
}

52
handlers/leaderboard.go Normal file
View File

@@ -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})
}
}

25
handlers/middleware.go Normal file
View File

@@ -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)
}
}
}

84
handlers/player_stats.go Normal file
View File

@@ -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)
}
}

118
handlers/session_end.go Normal file
View File

@@ -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,
})
}
}

133
handlers/session_start.go Normal file
View File

@@ -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
}

61
main.go Normal file
View File

@@ -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))
}

119
models/models.go Normal file
View File

@@ -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"`
}