503 lines
14 KiB
Go
503 lines
14 KiB
Go
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 adminToken string
|
|
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")
|
|
|
|
// Admin token for poker admin UI
|
|
adminToken = os.Getenv("ADMIN_TOKEN")
|
|
if adminToken == "" {
|
|
log.Fatal("ADMIN_TOKEN environment variable must be set")
|
|
}
|
|
|
|
// 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
|
|
|
|
// issueToken creates a new signed token, stores it in Redis, and returns it.
|
|
func issueToken(username string) (string, error) {
|
|
// Check if a valid session already exists
|
|
stored, err := rdb.Get(ctx, sessionKey(username)).Result()
|
|
if err == nil {
|
|
// Parse out the existing token and return it
|
|
parts := strings.SplitN(stored, ":", 2)
|
|
if len(parts) == 2 {
|
|
// Refresh TTL and return the existing token
|
|
rdb.Expire(ctx, sessionKey(username), sessionTTL)
|
|
return username + ":" + parts[0], nil
|
|
}
|
|
}
|
|
|
|
// No valid session — issue a new one
|
|
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 username + ":" + 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
|
|
}
|
|
|
|
// adminAuthMiddleware checks the Authorization header against ADMIN_TOKEN.
|
|
func adminAuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != adminToken {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|