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" "wizards.cyou/overte-api/poker" ) var rdb *redis.Client var ctx = context.Background() var domainAdminUser string var domainAdminPass string var hmacSecret []byte var pokerManager *poker.Manager // 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") // Poker manager pokerManager = poker.NewManager(rdb, ctx) pokerManager.ValidateToken = validateToken pokerManager.AdjustBalance = func(username string, delta int64) (int64, error) { return adjustBalance(username, delta) } pokerManager.BroadcastToTable = func(tableID string, msg interface{}) { hub.Broadcast(msg, nil) } pokerManager.SendToUser = func(username string, msg interface{}) { hub.SendToUser(username, msg) } if err := pokerManager.LoadTables(); err != nil { log.Printf("Warning: could not load poker tables: %v", err) } 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) pokerManager.RegisterRoutes(http.DefaultServeMux, adminAuthMiddleware) 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 func issueToken(username string) (string, error) { stored, err := rdb.Get(ctx, sessionKey(username)).Result() if err == nil { parts := strings.SplitN(stored, ":", 2) if len(parts) == 2 { rdb.Expire(ctx, sessionKey(username), sessionTTL) return parts[0], nil } } ts := time.Now().Unix() token := makeToken(username, ts) 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(map[string]string{"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}) } // --- Poker helpers --- // adjustBalance atomically adjusts a user's VERT balance. // delta can be negative (deduct) or positive (add). func adjustBalance(username string, delta int64) (int64, error) { var newBalance float64 err := rdb.Watch(ctx, func(tx *redis.Tx) error { balance, err := tx.HGet(ctx, "balances", username).Float64() if err == redis.Nil { balance = 0 } else if err != nil { return err } if delta < 0 && int64(balance) < -delta { return errors.New("insufficient balance") } _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.HIncrByFloat(ctx, "balances", username, float64(delta)) return nil }) if err == nil { newBalance = balance + float64(delta) } return err }, "balances") return int64(newBalance), err } func adminAuthMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { username, err := validateToken(r) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if username != "nak" { http.Error(w, "forbidden", http.StatusForbidden) return } next(w, r) } }