initial commit
This commit is contained in:
commit
a2e07963d9
7 changed files with 710 additions and 0 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# built binary
|
||||
overte-api
|
||||
|
||||
# backup files
|
||||
*.bak
|
||||
|
||||
# Go
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
/vendor/
|
||||
|
||||
# env / secrets
|
||||
.env
|
||||
*.env
|
||||
4
README.md
Normal file
4
README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
Overte API
|
||||
==========
|
||||
|
||||
HUMAN IN THE LOOP vibe coded API for my Overte domain
|
||||
6
build.sh
Executable file
6
build.sh
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Building overte-api..."
|
||||
go build -o overte-api .
|
||||
echo "Done."
|
||||
11
go.mod
Normal file
11
go.mod
Normal file
|
|
@ -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
|
||||
)
|
||||
10
go.sum
Normal file
10
go.sum
Normal file
|
|
@ -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=
|
||||
239
hub.go
Normal file
239
hub.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
425
main.go
Normal file
425
main.go
Normal file
|
|
@ -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})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue