initial
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.db*
|
||||
|
||||
horchposten
|
||||
208
AGENTS.md
Normal file
208
AGENTS.md
Normal 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
29
Containerfile
Normal 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
18
build.sh
Executable 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
92
db/db.go
Normal 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
54
deployment.yaml
Normal 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
19
go.mod
Normal 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
27
go.sum
Normal 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
590
handlers/admin.go
Normal 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, ¬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 = `
|
||||
<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"}}▲{{else}}▼{{end}}</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<a href="?sort={{$col}}&dir=asc{{if $.Search}}&q={{$.Search}}{{end}}{{kpAmp $.Key}}">
|
||||
{{$col}} <span class="sort-arrow">▲</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}}">← Prev</a>
|
||||
{{else}}
|
||||
<span class="disabled">← 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 →</a>
|
||||
{{else}}
|
||||
<span class="disabled">Next →</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> › {{.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
42
handlers/helpers.go
Normal 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
52
handlers/leaderboard.go
Normal 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
25
handlers/middleware.go
Normal 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
84
handlers/player_stats.go
Normal 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
118
handlers/session_end.go
Normal 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
133
handlers/session_start.go
Normal 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
61
main.go
Normal 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
119
models/models.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user