From a2e07963d998678d8962ae01f065c8a9649104cc Mon Sep 17 00:00:00 2001 From: nak Date: Sun, 15 Mar 2026 21:35:46 +0000 Subject: [PATCH] initial commit --- .gitignore | 15 ++ README.md | 4 + build.sh | 6 + go.mod | 11 ++ go.sum | 10 ++ hub.go | 239 ++++++++++++++++++++++++++++++ main.go | 425 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 710 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 build.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hub.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a7df69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# built binary +overte-api + +# backup files +*.bak + +# Go +*.exe +*.test +*.out +/vendor/ + +# env / secrets +.env +*.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..9427a07 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +Overte API +========== + +HUMAN IN THE LOOP vibe coded API for my Overte domain diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..66fd332 --- /dev/null +++ b/build.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -e + +echo "Building overte-api..." +go build -o overte-api . +echo "Done." diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..053ca0f --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module wizards.cyou/overte-api + +go 1.22.2 + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + go.uber.org/atomic v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..049c9b9 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= diff --git a/hub.go b/hub.go new file mode 100644 index 0000000..1a41bce --- /dev/null +++ b/hub.go @@ -0,0 +1,239 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// --- Message types --- +// +// Server -> client: +// {"type": "user:connected", "username": "nak"} +// {"type": "user:disconnected", "username": "nak"} +// {"type": "ping"} +// +// Client -> server: +// {"type": "pong"} +// {"type": "recall", "username": "nak"} (domain script relays to all clients) +// +// Any connected client can send a message; the hub broadcasts it to all others. +// This keeps the server simple and lets the domain script act as a relay for +// Overte-side events without needing separate channels for each use case. + +type Message struct { + Type string `json:"type"` + Username string `json:"username,omitempty"` + Payload string `json:"payload,omitempty"` +} + +// --- Hub --- + +type Hub struct { + mu sync.RWMutex + clients map[*Client]bool +} + +func newHub() *Hub { + return &Hub{clients: make(map[*Client]bool)} +} + +func (h *Hub) register(c *Client) { + h.mu.Lock() + h.clients[c] = true + h.mu.Unlock() + log.Printf("[WS] client connected (total: %d)", h.count()) +} + +func (h *Hub) unregister(c *Client) { + h.mu.Lock() + delete(h.clients, c) + h.mu.Unlock() + log.Printf("[WS] client disconnected (total: %d)", h.count()) +} + +func (h *Hub) count() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +} + +// Broadcast sends a message to all connected clients except the sender. +// Pass nil sender to broadcast to everyone. +func (h *Hub) Broadcast(msg Message, sender *Client) { + data, err := json.Marshal(msg) + if err != nil { + return + } + h.mu.RLock() + defer h.mu.RUnlock() + for c := range h.clients { + if c == sender { + continue + } + select { + case c.send <- data: + default: + // slow client — drop the message rather than block + log.Printf("[WS] dropped message to slow client") + } + } +} + +// --- Client --- + +type Client struct { + hub *Hub + conn *websocket.Conn + send chan []byte +} + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = 45 * time.Second + maxMessageSize = 4096 +) + +func (c *Client) readPump() { + defer func() { + c.hub.unregister(c) + c.conn.Close() + }() + c.conn.SetReadLimit(maxMessageSize) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + for { + _, data, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, + websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("[WS] read error: %v", err) + } + break + } + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + log.Printf("[WS] bad message: %s", data) + continue + } + // Relay to all other clients — domain script and entity scripts + // all share the same hub, so a recall from the tablet entity script + // arrives at the domain script automatically. + if msg.Type != "pong" { + c.hub.Broadcast(msg, c) + } + } +} + +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + for { + select { + case data, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// --- Upgrader & handler --- + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // Allow all origins — Overte scripts connect from localhost-ish contexts + CheckOrigin: func(r *http.Request) bool { return true }, +} + +var hub = newHub() + +func handleWS(w http.ResponseWriter, r *http.Request) { + log.Printf("[WS] new connection from %s (total after: %d)", r.RemoteAddr, hub.count()+1) + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Println("[WS] upgrade error:", err) + return + } + client := &Client{ + hub: hub, + conn: conn, + send: make(chan []byte, 64), + } + hub.register(client) + go client.writePump() + client.readPump() // blocks until disconnect +} + +// --- Presence loop --- +// +// Replaces the domain script's polling. The server watches nodes.json, +// detects connects/disconnects, pushes events to all WS clients, and +// continues to tick balances. Single source of truth for who is online. + +func presenceLoop() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + prev := map[string]bool{} + + for range ticker.C { + users, err := getConnectedUsers() + if err != nil { + log.Println("[presence] error fetching nodes:", err) + continue + } + + curr := map[string]bool{} + for _, u := range users { + curr[u] = true + } + + // Detect joins + for u := range curr { + if !prev[u] { + log.Printf("[presence] connected: %s", u) + hub.Broadcast(Message{Type: "user:connected", Username: u}, nil) + } + } + + // Detect leaves + for u := range prev { + if !curr[u] { + log.Printf("[presence] disconnected: %s", u) + hub.Broadcast(Message{Type: "user:disconnected", Username: u}, nil) + } + } + + // Tick balances for connected users + for u := range curr { + if _, err := rdb.HIncrByFloat(ctx, "balances", u, 1.0).Result(); err != nil { + log.Printf("[presence] tick error for %s: %v", u, err) + } + } + + prev = curr + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..13cf64b --- /dev/null +++ b/main.go @@ -0,0 +1,425 @@ +package main + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +var rdb *redis.Client +var ctx = context.Background() +var domainAdminUser string +var domainAdminPass string +var hmacSecret []byte + +// Shape definitions: name -> face count -> price (100 * faces) +var shapes = map[string]int{ + "tetrahedron": 4, + "hexahedron": 6, + "octahedron": 8, + "dodecahedron": 12, + "icosahedron": 20, +} + +func shapePrice(name string) (int64, bool) { + faces, ok := shapes[name] + if !ok { + return 0, false + } + return int64(faces * 100), true +} + +type Node struct { + Type string `json:"type"` + Username string `json:"username"` +} + +type NodesResponse struct { + Nodes []Node `json:"nodes"` +} + +// --- Main --- + +func main() { + domainAdminUser = os.Getenv("DOMAIN_ADMIN_USER") + domainAdminPass = os.Getenv("DOMAIN_ADMIN_PASS") + if domainAdminUser == "" || domainAdminPass == "" { + log.Fatal("DOMAIN_ADMIN_USER and DOMAIN_ADMIN_PASS environment variables must be set") + } + + // Load or generate HMAC secret + secret := os.Getenv("HMAC_SECRET") + if secret == "" { + log.Println("HMAC_SECRET not set, generating ephemeral secret (tokens won't survive restarts)") + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + log.Fatal("Failed to generate HMAC secret:", err) + } + hmacSecret = buf + } else { + hmacSecret = []byte(secret) + } + + rdb = redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + }) + if _, err := rdb.Ping(ctx).Result(); err != nil { + log.Fatal("Could not connect to Redis: ", err) + } + log.Println("Connected to Redis") + + go presenceLoop() + + http.HandleFunc("/ws", handleWS) + http.HandleFunc("/api/balance/", handleBalance) + http.HandleFunc("/api/session", handleSession) + http.HandleFunc("/api/inventory/", handleInventory) + http.HandleFunc("/api/purchase", handlePurchase) + http.HandleFunc("/api/connected", handleConnected) + http.HandleFunc("/api/recall", handleRecall) + + log.Println("Listening on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +// --- Domain helpers --- + +func getConnectedUsers() ([]string, error) { + req, err := http.NewRequest("GET", "http://localhost:40100/nodes.json", nil) + if err != nil { + return nil, err + } + req.SetBasicAuth(domainAdminUser, domainAdminPass) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var nodesResp NodesResponse + if err := json.Unmarshal(body, &nodesResp); err != nil { + return nil, err + } + var users []string + for _, node := range nodesResp.Nodes { + if node.Type == "agent" && node.Username != "" { + users = append(users, node.Username) + } + } + return users, nil +} + +func isUserConnected(username string) (bool, error) { + users, err := getConnectedUsers() + if err != nil { + return false, err + } + for _, u := range users { + if u == username { + return true, nil + } + } + return false, nil +} + +// --- Token helpers --- + +// makeToken returns a hex HMAC-SHA256 of "username:timestamp" using hmacSecret. +func makeToken(username string, ts int64) string { + msg := []byte(username + ":" + strconv.FormatInt(ts, 10)) + mac := hmac.New(sha256.New, hmacSecret) + mac.Write(msg) + return hex.EncodeToString(mac.Sum(nil)) +} + +func itoa(n int64) string { + return strconv.FormatInt(n, 10) +} + +// sessionKey returns the Redis key for a user's session token. +func sessionKey(username string) string { + return "session:" + username +} + +const sessionTTL = 6 * time.Minute // token valid for 6 min; client refreshes every 5 + +// issueToken creates a new signed token, stores it in Redis, and returns it. +func issueToken(username string) (string, error) { + ts := time.Now().Unix() + token := makeToken(username, ts) + // Store as "token:timestamp" so we can re-verify the HMAC on use + val := token + ":" + itoa(ts) + if err := rdb.Set(ctx, sessionKey(username), val, sessionTTL).Err(); err != nil { + return "", err + } + return token, nil +} + +// validateToken checks the Authorization header against the stored token. +// Returns the username on success or an error. +func validateToken(r *http.Request) (string, error) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return "", errors.New("missing bearer token") + } + provided := strings.TrimPrefix(auth, "Bearer ") + + // Token format in header: "username:hextoken" + parts := strings.SplitN(provided, ":", 2) + if len(parts) != 2 { + return "", errors.New("malformed token") + } + username, hexToken := parts[0], parts[1] + + stored, err := rdb.Get(ctx, sessionKey(username)).Result() + if err == redis.Nil { + return "", errors.New("no active session") + } else if err != nil { + return "", err + } + + // stored format: "hmac:timestamp" + storedParts := strings.SplitN(stored, ":", 2) + if len(storedParts) != 2 { + return "", errors.New("corrupt session data") + } + if !hmac.Equal([]byte(hexToken), []byte(storedParts[0])) { + return "", errors.New("invalid token") + } + + // Refresh TTL on valid use + rdb.Expire(ctx, sessionKey(username), sessionTTL) + + return username, nil +} + +// --- HTTP handlers --- + +// GET /api/balance/:username +func handleBalance(w http.ResponseWriter, r *http.Request) { + username := r.URL.Path[len("/api/balance/"):] + if username == "" { + http.Error(w, "missing username", http.StatusBadRequest) + return + } + balance, err := rdb.HGet(ctx, "balances", username).Float64() + if err == redis.Nil { + balance = 0 + } else if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "username": username, + "balance": int64(balance), + }) +} + +// POST /api/session +// Body: {"username": "..."} +// Verifies user is in nodes.json, issues a signed session token. +func handleSession(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Username string `json:"username"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Username == "" { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + + connected, err := isUserConnected(body.Username) + if err != nil { + log.Printf("Session check error for %s: %v", body.Username, err) + http.Error(w, "could not verify connection", http.StatusServiceUnavailable) + return + } + if !connected { + http.Error(w, "user not connected to domain", http.StatusForbidden) + return + } + + token, err := issueToken(body.Username) + if err != nil { + http.Error(w, "could not create session", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + // Return as "username:token" so client has one opaque string to store + "token": body.Username + ":" + token, + }) +} + +// GET /api/inventory/:username +func handleInventory(w http.ResponseWriter, r *http.Request) { + username := r.URL.Path[len("/api/inventory/"):] + if username == "" { + http.Error(w, "missing username", http.StatusBadRequest) + return + } + + raw, err := rdb.HGetAll(ctx, "inventory:"+username).Result() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + inventory := make(map[string]int64) + for shape := range shapes { + inventory[shape] = 0 + } + for k, v := range raw { + n, parseErr := strconv.ParseInt(v, 10, 64) + if parseErr == nil { + inventory[k] = n + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "username": username, + "inventory": inventory, + }) +} + +// POST /api/purchase +// Header: Authorization: Bearer username:token +// Body: {"shape": "tetrahedron"} +// Atomically deducts verts and increments inventory count. +func handlePurchase(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + username, err := validateToken(r) + if err != nil { + http.Error(w, "unauthorized: "+err.Error(), http.StatusUnauthorized) + return + } + + var body struct { + Shape string `json:"shape"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Shape == "" { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + + price, ok := shapePrice(body.Shape) + if !ok { + http.Error(w, "unknown shape", http.StatusBadRequest) + return + } + + // Atomic purchase: WATCH balances hash, check sufficient funds, deduct, increment inventory + balanceKey := "balances" + inventoryKey := "inventory:" + username + + txErr := rdb.Watch(ctx, func(tx *redis.Tx) error { + balance, err := tx.HGet(ctx, balanceKey, username).Float64() + if err == redis.Nil { + balance = 0 + } else if err != nil { + return err + } + + if int64(balance) < price { + return errors.New("insufficient balance") + } + + _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.HIncrByFloat(ctx, balanceKey, username, float64(-price)) + pipe.HIncrBy(ctx, inventoryKey, body.Shape, 1) + return nil + }) + return err + }, balanceKey) + + if txErr != nil { + if txErr.Error() == "insufficient balance" { + http.Error(w, "insufficient balance", http.StatusPaymentRequired) + return + } + log.Printf("Purchase tx error for %s/%s: %v", username, body.Shape, txErr) + http.Error(w, "transaction failed", http.StatusInternalServerError) + return + } + + // Read back new state + newBalance, _ := rdb.HGet(ctx, balanceKey, username).Float64() + newCount, _ := rdb.HGet(ctx, inventoryKey, body.Shape).Int64() + + log.Printf("Purchase: %s bought %s for %d verts (balance now %.0f)", username, body.Shape, price, newBalance) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "shape": body.Shape, + "price": price, + "balance": int64(newBalance), + "owned": newCount, + }) +} + +// GET /api/connected +// Returns the list of currently connected usernames. Public endpoint — no auth required. +func handleConnected(w http.ResponseWriter, r *http.Request) { + users, err := getConnectedUsers() + if err != nil { + log.Println("Error fetching connected users:", err) + http.Error(w, "could not fetch connected users", http.StatusServiceUnavailable) + return + } + if users == nil { + users = []string{} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "users": users, + }) +} + +// POST /api/recall +// Header: Authorization: Bearer username:token +// Publishes a recall message into the WS hub so the domain script +// deletes the user's clones immediately. +func handleRecall(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + username, err := validateToken(r) + if err != nil { + http.Error(w, "unauthorized: "+err.Error(), http.StatusUnauthorized) + return + } + hub.Broadcast(Message{Type: "recall", Username: username}, nil) + log.Printf("Recall broadcast for %s", username) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) +}