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