package main import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "log" "net/http" "os" "sort" "strings" "sync" "time" "SimpleArmaAdmin/internal/auth" "SimpleArmaAdmin/internal/crypto" internal_nats "SimpleArmaAdmin/internal/nats" "SimpleArmaAdmin/internal/parser" "SimpleArmaAdmin/internal/webauthn" "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/nats-io/nats.go" ) var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } // ============================================================================ // DEV MODE: In-memory stores // ============================================================================ type DevCommunity struct { ID string `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"createdAt"` } type DevUser struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` PasswordHash string `json:"-"` CreatedAt time.Time `json:"createdAt"` } type DevMembership struct { UserID string `json:"userId"` CommunityID string `json:"communityId"` Role string `json:"role"` // "owner" | "admin" | "member" } type DevServer struct { ID string `json:"id"` CommunityID string `json:"communityId"` Name string `json:"name"` Description string `json:"description"` WorkerID string `json:"workerId"` StorageID string `json:"storageId"` LogPath string `json:"logPath"` EncryptedRcon string `json:"encryptedRcon"` EncryptedAutoMessages string `json:"encryptedAutoMessages"` // E2EE blob for AutoMsgs & MOTD MockActive bool `json:"mockActive"` CreatedAt time.Time `json:"createdAt"` } type DevPlayerNote struct { ID string `json:"id"` CommunityID string `json:"communityId"` PlayerNameHash string `json:"playerNameHash"` Category string `json:"category"` // "warning" | "info" | "custom" Content string `json:"content"` // Plaintext for simplicity in dev CreatedBy string `json:"createdBy"` CreatedAt time.Time `json:"createdAt"` } type DevPermission struct { ID string `json:"id"` CommunityID string `json:"communityId"` UserID string `json:"userId"` Username string `json:"username"` ServerID string `json:"serverId"` ServerName string `json:"serverName"` Scopes []string `json:"scopes"` GrantedBy string `json:"grantedBy"` GrantedAt time.Time `json:"grantedAt"` } type DevPlayer struct { ID string `json:"id"` CommunityID string `json:"communityId"` NameHash string `json:"nameHash"` // Blind Index Data string `json:"data"` // Encrypted JSON (Name, IP, SteamID, etc.) JoinedAt time.Time `json:"joinedAt"` } type DevBan struct { ID string `json:"id"` CommunityID string `json:"communityId"` PlayerNameHash string `json:"playerNameHash"` // Blind Index for searching Data string `json:"data"` // Encrypted JSON (PlayerName, Reason, BannedBy) BannedAt time.Time `json:"bannedAt"` ExpiresAt *time.Time `json:"expiresAt"` // nil = permanent } type DevNode struct { ID string `json:"id"` CommunityID string `json:"-"` Name string `json:"name"` Type string `json:"type"` // "storage" | "worker" Token string `json:"-"` // only returned at creation Endpoint string `json:"endpoint"` Status string `json:"status"` // "pending" | "online" | "offline" Version string `json:"version"` LastSeen *time.Time `json:"lastSeen"` CreatedAt time.Time `json:"createdAt"` } type DevPasskey struct { ID string `json:"id"` UserID string `json:"userId"` CommunityID string `json:"communityId"` CredentialID string `json:"credentialId"` Name string `json:"name"` CreatedAt time.Time `json:"createdAt"` } type DevStore struct { communities map[string]*DevCommunity users map[string]*DevUser usersByName map[string]*DevUser memberships []DevMembership servers map[string]*DevServer permissions map[string]*DevPermission players map[string]*DevPlayer // key: communityID+":"+playerName roster map[string]*DevPlayer // key: communityID+":"+playerNameHash playerNotes []DevPlayerNote bans map[string]*DevBan // key: ban ID passkeys map[string]*DevPasskey nodes map[string]*DevNode logs []LogEntry // In-memory log history for dev mode mockCancels map[string]context.CancelFunc mu sync.RWMutex } type LogEntry struct { CommunityID string `json:"communityId"` Data []byte `json:"data"` // Encrypted payload } func newDevStore() *DevStore { return &DevStore{ communities: make(map[string]*DevCommunity), users: make(map[string]*DevUser), usersByName: make(map[string]*DevUser), servers: make(map[string]*DevServer), permissions: make(map[string]*DevPermission), players: make(map[string]*DevPlayer), roster: make(map[string]*DevPlayer), bans: make(map[string]*DevBan), passkeys: make(map[string]*DevPasskey), nodes: make(map[string]*DevNode), mockCancels: make(map[string]context.CancelFunc), } } func generateNodeToken() string { return "nk_" + strings.ReplaceAll(uuid.NewString(), "-", "") + strings.ReplaceAll(uuid.NewString(), "-", "") } var devMockLogs = []string{ "12:30:01.122 SCRIPT : [RJSSupport][Chat] [Global] Zauberklöte: hi, leute kurze frage. zock seit monaten wieder mal arma, was ist aus dem gtg#4 und #5 geworden, da ist ja nix los", "09:37:50.865 DEFAULT : BattlEye Server: 'Player #0 Mike1Delta (92.209.175.19:6679) connected'", "13:29:19.727 SCRIPT : [RJSSupport][Chat] [Global] 纱雾.: WHAT", "09:38:53.842 DEFAULT : BattlEye Server: 'Player #0 Mike1Delta disconnected'", "14:56:34.622 SCRIPT : [RJSSupport][Chat] [Global] Toope: help?", "15:04:22.868 SCRIPT : [RJSSupport][Chat] [Global] vatrano: Transpo 5-10min abwesend", "10:11:22.001 DEFAULT : BattlEye Server: 'Player #2 SgtPepper47 (195.60.71.82:2201) connected'", "10:15:00.123 SCRIPT : [RJSSupport][Chat] [Global] SgtPepper47: anyone in comms?", "10:22:44.500 DEFAULT : BattlEye Server: 'Player #3 Delta_Force_R (91.77.88.99:4411) connected'", "10:28:10.777 SCRIPT : [RJSSupport][Chat] [Global] Delta_Force_R: pushing north", "10:35:00.900 DEFAULT : BattlEye Server: 'Player #2 SgtPepper47 disconnected'", "10:40:18.200 SCRIPT : [RJSSupport][Chat] [Global] Zauberklöte: gg wp", } // mockPlayerIPs maps known mock player names to their IPs for JOIN events var mockPlayerIPs = map[string]string{ "Mike1Delta": "92.209.175.19", "Zauberklöte": "89.12.45.67", "纱雾": "103.45.78.90", "Toope": "178.22.33.44", "vatrano": "88.99.100.11", "SgtPepper47": "195.60.71.82", "Delta_Force_R": "91.77.88.99", } func extractPlayerName(content string) string { if strings.Contains(content, "connected to server") { return strings.TrimSpace(strings.Split(content, " connected")[0]) } if strings.Contains(content, "left the server") { return strings.TrimSpace(strings.Split(content, " left")[0]) } if strings.Contains(content, ":") { return strings.TrimSpace(strings.SplitN(content, ":", 2)[0]) } return "" } // ============================================================================ // GATEWAY // ============================================================================ type Gateway struct { natsClient *internal_nats.Client dashboards map[*websocket.Conn]bool mu sync.Mutex devMode bool store *DevStore } func (g *Gateway) startMock(communityID, serverID string) { g.store.mu.Lock() if _, exists := g.store.mockCancels[communityID]; exists { g.store.mu.Unlock() return } ctx, cancel := context.WithCancel(context.Background()) g.store.mockCancels[communityID] = cancel masterKey := []byte("this-is-a-32-byte-master-key-xyz") // Seed initial online players for name, ip := range mockPlayerIPs { nameHash := crypto.GenerateBlindIndex(name, masterKey) playerData, _ := json.Marshal(map[string]string{ "name": name, "ip": ip, }) encryptedData, _ := crypto.Encrypt(playerData, masterKey) key := communityID + ":" + nameHash p := &DevPlayer{ ID: uuid.NewString(), CommunityID: communityID, NameHash: nameHash, Data: base64.StdEncoding.EncodeToString(encryptedData), JoinedAt: time.Now().Add(-time.Duration(len(name)) * time.Minute), } g.store.players[key] = p g.store.roster[key] = p } // Seed mock infrastructure nodes seedTime := time.Now() storageNode := &DevNode{ ID: uuid.NewString(), CommunityID: communityID, Name: "Primary Storage", Type: "storage", Token: generateNodeToken(), Endpoint: "http://storage.example.com:9000", Status: "online", Version: "1.2.0", LastSeen: &seedTime, CreatedAt: time.Now().Add(-24 * time.Hour), } workerNode := &DevNode{ ID: uuid.NewString(), CommunityID: communityID, Name: "Log Worker #1", Type: "worker", Token: generateNodeToken(), Endpoint: "http://worker.example.com:8001", Status: "online", Version: "0.9.5", LastSeen: &seedTime, CreatedAt: time.Now().Add(-12 * time.Hour), } g.store.nodes[storageNode.ID] = storageNode g.store.nodes[workerNode.ID] = workerNode g.store.mu.Unlock() go func() { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() i := 0 for { select { case <-ctx.Done(): return case <-ticker.C: line := devMockLogs[i%len(devMockLogs)] event := parser.ParseLine(line) if event != nil { event.ServerID = serverID event.ServerName = "AMS-NODE-01" // Mock server name if event.PlayerName != "" { event.PlayerNameHash = crypto.GenerateBlindIndex(event.PlayerName, masterKey) } else { // Assign to SYSTEM if no player is present event.PlayerName = "SYSTEM" event.PlayerNameHash = "system-blind-index" } // Keep player list in sync with mock events g.store.mu.Lock() switch event.Type { case "JOIN": if event.PlayerName != "SYSTEM" { playerName := event.PlayerName ip := mockPlayerIPs[playerName] if ip == "" { ip = "10.0.0.1" } nameHash := crypto.GenerateBlindIndex(playerName, masterKey) playerData, _ := json.Marshal(map[string]string{ "name": playerName, "ip": ip, }) encryptedData, _ := crypto.Encrypt(playerData, masterKey) key := communityID + ":" + nameHash p := &DevPlayer{ ID: uuid.NewString(), CommunityID: communityID, NameHash: nameHash, Data: base64.StdEncoding.EncodeToString(encryptedData), JoinedAt: time.Now(), } g.store.players[key] = p g.store.roster[key] = p } case "LEAVE": if event.PlayerName != "SYSTEM" { nameHash := crypto.GenerateBlindIndex(event.PlayerName, masterKey) delete(g.store.players, communityID+":"+nameHash) } } // Simulate heartbeats for mock nodes hbNow := time.Now() for _, node := range g.store.nodes { if node.CommunityID == communityID { node.LastSeen = &hbNow node.Status = "online" } } onlineCount := 0 for _, p := range g.store.players { if p.CommunityID == communityID { onlineCount++ } } g.store.mu.Unlock() payload, err := json.Marshal(event) if err == nil { if encrypted, err := crypto.Encrypt(payload, masterKey); err == nil { g.broadcast(websocket.BinaryMessage, encrypted) } } telemetry, _ := json.Marshal(map[string]interface{}{ "type": "TELEMETRY", "fps": 45.5 + float64(time.Now().Unix()%5), "players": onlineCount, }) g.broadcast(websocket.TextMessage, telemetry) } i++ } } }() log.Printf("[DEV] Mock data stream started for community %s", communityID) } func (g *Gateway) stopMock(communityID string) { g.store.mu.Lock() defer g.store.mu.Unlock() if cancel, exists := g.store.mockCancels[communityID]; exists { cancel() delete(g.store.mockCancels, communityID) // Clear online players for this community for key, p := range g.store.players { if p.CommunityID == communityID { delete(g.store.players, key) } } log.Printf("[DEV] Mock data stream stopped for community %s", communityID) } } // broadcastEvent encrypts an event and broadcasts it to all dashboards func (g *Gateway) broadcastEvent(event *parser.LogEvent, serverID string) { masterKey := []byte("this-is-a-32-byte-master-key-xyz") event.ServerID = serverID event.ServerName = "AMS-NODE-01" // Mock if event.PlayerName == "" { event.PlayerName = "SYSTEM" event.PlayerNameHash = "system-blind-index" } else if event.PlayerNameHash == "" { event.PlayerNameHash = crypto.GenerateBlindIndex(event.PlayerName, masterKey) } payload, err := json.Marshal(event) if err != nil { return } if encrypted, err := crypto.Encrypt(payload, masterKey); err == nil { g.broadcast(websocket.BinaryMessage, encrypted) } } // ============================================================================ // HELPERS // ============================================================================ func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) } func (g *Gateway) requireAuth(r *http.Request) (*auth.Claims, error) { authHeader := r.Header.Get("Authorization") if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { return nil, fmt.Errorf("missing token") } return auth.VerifyJWT(strings.TrimPrefix(authHeader, "Bearer ")) } // ============================================================================ // WEBSOCKET // ============================================================================ func (g *Gateway) handleWebSocket(w http.ResponseWriter, r *http.Request) { role := r.URL.Query().Get("role") conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("Upgrade error: %v", err) return } if role == "dashboard" { g.mu.Lock() g.dashboards[conn] = true g.mu.Unlock() } defer func() { if role == "dashboard" { g.mu.Lock() delete(g.dashboards, conn) g.mu.Unlock() } conn.Close() }() communityID := "comm-123-abc" for { messageType, p, err := conn.ReadMessage() if err != nil { break } if role != "dashboard" { if messageType == websocket.BinaryMessage { g.store.mu.Lock() g.store.logs = append(g.store.logs, LogEntry{ CommunityID: communityID, Data: p, }) if len(g.store.logs) > 1000 { g.store.logs = g.store.logs[1:] } g.store.mu.Unlock() g.natsClient.PublishLog(context.Background(), communityID, "live", p) g.broadcast(websocket.BinaryMessage, p) } else { g.natsClient.Conn.Publish("telemetry."+communityID, p) } } } } func (g *Gateway) broadcast(messageType int, data []byte) { if messageType == websocket.BinaryMessage { g.store.mu.Lock() g.store.logs = append(g.store.logs, LogEntry{ CommunityID: "dev-community", // Hardcoded for dev mode simplicity Data: data, }) // Keep last 1000 logs if len(g.store.logs) > 1000 { g.store.logs = g.store.logs[1:] } g.store.mu.Unlock() } g.mu.Lock() defer g.mu.Unlock() for client := range g.dashboards { if err := client.WriteMessage(messageType, data); err != nil { client.Close() delete(g.dashboards, client) } } } // ============================================================================ // REGISTRATION // ============================================================================ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { Username string `json:"username"` Email string `json:"email"` Password string `json:"password"` CommunityName string `json:"communityName"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } req.Username = strings.TrimSpace(req.Username) if req.Username == "" { writeError(w, http.StatusBadRequest, "Username is required") return } if err := auth.ValidatePasswordStrength(req.Password); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } hash, err := auth.HashPassword(req.Password) if err != nil { http.Error(w, "Failed to hash password", http.StatusInternalServerError) return } if g.devMode { g.store.mu.Lock() defer g.store.mu.Unlock() if _, exists := g.store.usersByName[strings.ToLower(req.Username)]; exists { writeError(w, http.StatusConflict, "Username already taken") return } communityID := uuid.NewString() communityName := req.CommunityName if communityName == "" { communityName = req.Username + "'s Team" } community := &DevCommunity{ ID: communityID, Name: communityName, CreatedAt: time.Now(), } g.store.communities[communityID] = community userID := uuid.NewString() user := &DevUser{ ID: userID, Username: req.Username, Email: req.Email, PasswordHash: hash, CreatedAt: time.Now(), } g.store.users[userID] = user g.store.usersByName[strings.ToLower(req.Username)] = user membership := DevMembership{ UserID: userID, CommunityID: communityID, Role: "owner", } g.store.memberships = append(g.store.memberships, membership) log.Printf("[DEV] Registered user: %s, Created community: %s", req.Username, communityName) token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour) if err != nil { http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]string{ "status": "success", "token": token, "userId": userID, "communityId": communityID, "username": req.Username, "role": "owner", "masterKey": "this-is-a-32-byte-master-key-xyz", }) return } // Non-dev mode fallback userID := "user-" + req.Username communityID := "comm-" + req.Username token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour) if err != nil { http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]string{ "status": "success", "token": token, "userId": userID, "communityId": communityID, "username": req.Username, "role": "owner", "masterKey": "this-is-a-32-byte-master-key-xyz", }) } func (g *Gateway) handleRegisterPasskeyBegin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` DisplayName string `json:"displayName"` Email string `json:"email"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } options, err := webauthn.CreateRegistrationOptions( "user-"+req.Username, req.Username, req.DisplayName, "ArmaAdmin Zero-Knowledge Cloud", "localhost", ) if err != nil { http.Error(w, "Failed to create options", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(options) } func (g *Gateway) handleRegisterPasskeyFinish(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{ "status": "success", "message": "Passkey registered successfully", }) } // ============================================================================ // PASSKEY MANAGEMENT (authenticated: list, add, delete) // ============================================================================ func detectDeviceName(userAgent string) string { ua := strings.ToLower(userAgent) var os string switch { case strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad"): os = "iPhone/iPad" case strings.Contains(ua, "android"): os = "Android" case strings.Contains(ua, "mac os x"): os = "macOS" case strings.Contains(ua, "windows"): os = "Windows" case strings.Contains(ua, "linux"): os = "Linux" default: os = "Unknown Device" } var browser string switch { case strings.Contains(ua, "firefox"): browser = "Firefox" case strings.Contains(ua, "edg/"): browser = "Edge" case strings.Contains(ua, "chrome"): browser = "Chrome" case strings.Contains(ua, "safari"): browser = "Safari" default: browser = "Browser" } return browser + " on " + os } func (g *Gateway) handlePasskeys(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: claims, err := g.requireAuth(r) if err != nil { writeError(w, http.StatusUnauthorized, "Unauthorized") return } g.store.mu.RLock() var list []DevPasskey for _, pk := range g.store.passkeys { if pk.UserID == claims.UserID { list = append(list, *pk) } } g.store.mu.RUnlock() if list == nil { list = []DevPasskey{} } sort.Slice(list, func(i, j int) bool { return list[i].CreatedAt.Before(list[j].CreatedAt) }) writeJSON(w, http.StatusOK, map[string]interface{}{"passkeys": list}) case http.MethodDelete: claims, err := g.requireAuth(r) if err != nil { writeError(w, http.StatusUnauthorized, "Unauthorized") return } id := r.URL.Query().Get("id") if id == "" { writeError(w, http.StatusBadRequest, "Missing id") return } g.store.mu.Lock() pk, exists := g.store.passkeys[id] if !exists || pk.UserID != claims.UserID { g.store.mu.Unlock() writeError(w, http.StatusNotFound, "Passkey not found") return } delete(g.store.passkeys, id) g.store.mu.Unlock() writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func (g *Gateway) handleAddPasskeyBegin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } claims, err := g.requireAuth(r) if err != nil { writeError(w, http.StatusUnauthorized, "Unauthorized") return } g.store.mu.RLock() user, exists := g.store.users[claims.UserID] g.store.mu.RUnlock() if !exists { writeError(w, http.StatusNotFound, "User not found") return } options, err := webauthn.CreateRegistrationOptions( user.ID, user.Username, user.Username, "ArmaAdmin Zero-Knowledge Cloud", "localhost", ) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to create options") return } writeJSON(w, http.StatusOK, options) } func (g *Gateway) handleAddPasskeyFinish(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } claims, err := g.requireAuth(r) if err != nil { writeError(w, http.StatusUnauthorized, "Unauthorized") return } var req struct { ID string `json:"id"` Name string `json:"name"` } json.NewDecoder(r.Body).Decode(&req) name := req.Name if name == "" { name = detectDeviceName(r.Header.Get("User-Agent")) } pk := &DevPasskey{ ID: uuid.New().String(), UserID: claims.UserID, CommunityID: claims.CommunityID, CredentialID: req.ID, Name: name, CreatedAt: time.Now(), } g.store.mu.Lock() g.store.passkeys[pk.ID] = pk g.store.mu.Unlock() writeJSON(w, http.StatusOK, map[string]interface{}{"status": "success", "passkey": pk}) } // ============================================================================ // NODES // ============================================================================ func (g *Gateway) handleNodes(w http.ResponseWriter, r *http.Request) { claims, err := g.requireAuth(r) if err != nil { writeError(w, http.StatusUnauthorized, "Unauthorized") return } switch r.Method { case http.MethodGet: g.store.mu.RLock() var result []map[string]interface{} for _, n := range g.store.nodes { if n.CommunityID == claims.CommunityID { result = append(result, map[string]interface{}{ "id": n.ID, "name": n.Name, "type": n.Type, "endpoint": n.Endpoint, "status": n.Status, "version": n.Version, "lastSeen": n.LastSeen, "createdAt": n.CreatedAt, }) } } g.store.mu.RUnlock() if result == nil { result = []map[string]interface{}{} } writeJSON(w, http.StatusOK, map[string]interface{}{"nodes": result}) case http.MethodPost: var req struct { Name string `json:"name"` Type string `json:"type"` Endpoint string `json:"endpoint"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } if req.Type != "storage" && req.Type != "worker" { writeError(w, http.StatusBadRequest, "type must be 'storage' or 'worker'") return } token := generateNodeToken() node := &DevNode{ ID: uuid.NewString(), CommunityID: claims.CommunityID, Name: req.Name, Type: req.Type, Token: token, Endpoint: req.Endpoint, Status: "pending", CreatedAt: time.Now(), } g.store.mu.Lock() g.store.nodes[node.ID] = node g.store.mu.Unlock() writeJSON(w, http.StatusCreated, map[string]interface{}{ "id": node.ID, "name": node.Name, "type": node.Type, "endpoint": node.Endpoint, "status": node.Status, "version": node.Version, "lastSeen": node.LastSeen, "createdAt": node.CreatedAt, "token": token, }) case http.MethodDelete: nodeID := r.URL.Query().Get("id") if nodeID == "" { writeError(w, http.StatusBadRequest, "Missing id") return } g.store.mu.Lock() node, exists := g.store.nodes[nodeID] if !exists || node.CommunityID != claims.CommunityID { g.store.mu.Unlock() writeError(w, http.StatusNotFound, "Node not found") return } delete(g.store.nodes, nodeID) g.store.mu.Unlock() writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if token == "" { http.Error(w, "Missing token", http.StatusUnauthorized) return } g.store.mu.RLock() var node *DevNode for _, n := range g.store.nodes { if n.Token == token { node = n break } } g.store.mu.RUnlock() if node == nil { http.Error(w, "Invalid token", http.StatusUnauthorized) return } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("[NODE] WebSocket upgrade failed for node %s: %v", node.Name, err) return } defer conn.Close() now := time.Now() g.store.mu.Lock() node.Status = "online" node.LastSeen = &now g.store.mu.Unlock() log.Printf("[NODE] %s (%s) connected via WebSocket", node.Name, node.ID) conn.WriteJSON(map[string]string{"type": "connected", "nodeId": node.ID, "name": node.Name}) for { var msg struct { Type string `json:"type"` Version string `json:"version"` } if err := conn.ReadJSON(&msg); err != nil { break } if msg.Type == "heartbeat" { hbNow := time.Now() g.store.mu.Lock() node.LastSeen = &hbNow node.Status = "online" if msg.Version != "" { node.Version = msg.Version } g.store.mu.Unlock() } } g.store.mu.Lock() node.Status = "offline" g.store.mu.Unlock() log.Printf("[NODE] %s (%s) disconnected", node.Name, node.ID) } func (g *Gateway) handleDevNodePing(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if !g.devMode { http.Error(w, "Not found", http.StatusNotFound) return } nodeID := r.URL.Query().Get("id") if nodeID == "" { writeError(w, http.StatusBadRequest, "Missing id") return } g.store.mu.Lock() node, exists := g.store.nodes[nodeID] if !exists { g.store.mu.Unlock() writeError(w, http.StatusNotFound, "Node not found") return } now := time.Now() node.LastSeen = &now node.Status = "online" token := node.Token name := node.Name g.store.mu.Unlock() writeJSON(w, http.StatusOK, map[string]string{ "status": "pinged", "nodeId": nodeID, "name": name, "token": token, }) } func (g *Gateway) monitorNodes() { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for range ticker.C { now := time.Now() g.store.mu.Lock() for _, node := range g.store.nodes { if node.Status == "online" && node.LastSeen != nil && now.Sub(*node.LastSeen) > 90*time.Second { node.Status = "offline" log.Printf("[NODE] Node %s (%s) went offline", node.Name, node.ID) } } g.store.mu.Unlock() } } // ============================================================================ // LOGIN // ============================================================================ func (g *Gateway) handleLoginPassword(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } if g.devMode { g.store.mu.RLock() user, exists := g.store.usersByName[strings.ToLower(req.Username)] var userMemberships []DevMembership if exists { for _, m := range g.store.memberships { if m.UserID == user.ID { userMemberships = append(userMemberships, m) } } } g.store.mu.RUnlock() if !exists || len(userMemberships) == 0 { writeError(w, http.StatusUnauthorized, "Invalid credentials or no community access") return } ok, err := auth.VerifyPassword(req.Password, user.PasswordHash) if err != nil || !ok { writeError(w, http.StatusUnauthorized, "Invalid credentials") return } // Use the first community as default for the initial token defaultMembership := userMemberships[0] // Map community names type communityInfo struct { ID string `json:"id"` Name string `json:"name"` Role string `json:"role"` } var communities []communityInfo g.store.mu.RLock() for _, m := range userMemberships { if comm, ok := g.store.communities[m.CommunityID]; ok { communities = append(communities, communityInfo{ ID: comm.ID, Name: comm.Name, Role: m.Role, }) } } g.store.mu.RUnlock() token, err := auth.GenerateJWT(user.ID, defaultMembership.CommunityID, user.Username, 7*24*time.Hour) if err != nil { http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "status": "success", "token": token, "userId": user.ID, "communityId": defaultMembership.CommunityID, "username": user.Username, "role": defaultMembership.Role, "communities": communities, "masterKey": "this-is-a-32-byte-master-key-xyz", }) return } // Non-dev mode: demo fallback log.Printf("[DEMO] Login attempt for user: %s (auto-accepting)", req.Username) userID := "user-" + req.Username communityID := "comm-" + req.Username token, err := auth.GenerateJWT(userID, communityID, req.Username, 7*24*time.Hour) if err != nil { http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]string{ "status": "success", "token": token, "userId": userID, "communityId": communityID, "username": req.Username, "role": "owner", "masterKey": "this-is-a-32-byte-master-key-xyz", }) } func (g *Gateway) handleLoginPasskeyBegin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } options, err := webauthn.CreateAuthenticationOptions("localhost", []string{}) if err != nil { http.Error(w, "Failed to create options", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(options) } func (g *Gateway) handleLoginPasskeyFinish(w http.ResponseWriter, r *http.Request) { token, err := auth.GenerateJWT("user-demo", "comm-demo", "demo", 7*24*time.Hour) if err != nil { http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]string{ "status": "success", "token": token, "userId": "user-demo", "communityId": "comm-demo", "username": "demo", "role": "owner", "masterKey": "this-is-a-32-byte-master-key-xyz", }) } // ============================================================================ // SESSION & PROFILE // ============================================================================ func (g *Gateway) handleLogout(w http.ResponseWriter, r *http.Request) { // TODO: Invalidate session in database writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"}) } func (g *Gateway) handleMe(w http.ResponseWriter, r *http.Request) { claims, err := g.requireAuth(r) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } resp := map[string]string{ "userId": claims.UserID, "username": claims.Username, "communityId": claims.CommunityID, "role": "owner", } if g.devMode { g.store.mu.RLock() if user, exists := g.store.users[claims.UserID]; exists { resp["role"] = user.Role resp["email"] = user.Email } g.store.mu.RUnlock() } writeJSON(w, http.StatusOK, resp) } func (g *Gateway) handleProfile(w http.ResponseWriter, r *http.Request) { claims, err := g.requireAuth(r) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } switch r.Method { case http.MethodGet: resp := map[string]string{ "id": claims.UserID, "username": claims.Username, "communityId": claims.CommunityID, "email": "", "role": "owner", } if g.devMode { g.store.mu.RLock() if user, exists := g.store.users[claims.UserID]; exists { resp["email"] = user.Email resp["role"] = user.Role } g.store.mu.RUnlock() } writeJSON(w, http.StatusOK, resp) case http.MethodPut: var req struct { Username string `json:"username"` Email string `json:"email"` OldPassword string `json:"oldPassword"` NewPassword string `json:"newPassword"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } if !g.devMode { writeError(w, http.StatusNotImplemented, "Profile update requires database") return } g.store.mu.Lock() defer g.store.mu.Unlock() user, exists := g.store.users[claims.UserID] if !exists { http.Error(w, "User not found", http.StatusNotFound) return } if req.Username != "" && req.Username != user.Username { newKey := strings.ToLower(req.Username) if _, taken := g.store.usersByName[newKey]; taken { writeError(w, http.StatusConflict, "Username already taken") return } delete(g.store.usersByName, strings.ToLower(user.Username)) user.Username = req.Username g.store.usersByName[newKey] = user } if req.Email != "" { user.Email = req.Email } if req.NewPassword != "" { ok, err := auth.VerifyPassword(req.OldPassword, user.PasswordHash) if err != nil || !ok { writeError(w, http.StatusUnauthorized, "Current password is incorrect") return } if err := auth.ValidatePasswordStrength(req.NewPassword); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } hash, err := auth.HashPassword(req.NewPassword) if err != nil { http.Error(w, "Failed to hash password", http.StatusInternalServerError) return } user.PasswordHash = hash } token, err := auth.GenerateJWT(user.ID, user.CommunityID, user.Username, 7*24*time.Hour) if err != nil { http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]string{ "status": "updated", "token": token, "username": user.Username, "email": user.Email, "role": user.Role, "communityId": user.CommunityID, }) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } // ============================================================================ // SERVER MANAGEMENT // ============================================================================ func (g *Gateway) handleServers(w http.ResponseWriter, r *http.Request) { claims, err := g.requireAuth(r) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } switch r.Method { case http.MethodGet: g.store.mu.RLock() var result []*DevServer for _, s := range g.store.servers { if s.CommunityID == claims.CommunityID { result = append(result, s) } } g.store.mu.RUnlock() if result == nil { result = []*DevServer{} } writeJSON(w, http.StatusOK, map[string]interface{}{"servers": result}) case http.MethodPost: var req struct { Name string `json:"name"` Description string `json:"description"` WorkerID string `json:"workerId"` StorageID string `json:"storageId"` LogPath string `json:"logPath"` EncryptedRcon string `json:"encryptedRcon"` EncryptedAutoMessages string `json:"encryptedAutoMessages"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { writeError(w, http.StatusBadRequest, "Server name is required") return } g.store.mu.Lock() isFirst := true for _, s := range g.store.servers { if s.CommunityID == claims.CommunityID { isFirst = false break } } serverID := uuid.NewString() server := &DevServer{ ID: serverID, CommunityID: claims.CommunityID, Name: req.Name, Description: req.Description, WorkerID: req.WorkerID, StorageID: req.StorageID, LogPath: req.LogPath, EncryptedRcon: req.EncryptedRcon, EncryptedAutoMessages: req.EncryptedAutoMessages, MockActive: g.devMode && isFirst, CreatedAt: time.Now(), } g.store.servers[serverID] = server g.store.mu.Unlock() if g.devMode && isFirst { g.startMock(claims.CommunityID, serverID) } writeJSON(w, http.StatusCreated, server) case http.MethodPut: var req struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` WorkerID string `json:"workerId"` StorageID string `json:"storageId"` LogPath string `json:"logPath"` EncryptedRcon string `json:"encryptedRcon"` EncryptedAutoMessages string `json:"encryptedAutoMessages"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } g.store.mu.Lock() defer g.store.mu.Unlock() server, exists := g.store.servers[req.ID] if !exists || server.CommunityID != claims.CommunityID { http.Error(w, "Server not found", http.StatusNotFound) return } if req.Name != "" { server.Name = req.Name } server.Description = req.Description server.WorkerID = req.WorkerID server.StorageID = req.StorageID server.LogPath = req.LogPath if req.EncryptedRcon != "" { server.EncryptedRcon = req.EncryptedRcon } if req.EncryptedAutoMessages != "" { server.EncryptedAutoMessages = req.EncryptedAutoMessages } // Broadcast update to Workers and Dashboards updateMsg, _ := json.Marshal(map[string]interface{}{ "type": "CONFIG_UPDATE", "server": server, }) g.broadcast(websocket.TextMessage, updateMsg) writeJSON(w, http.StatusOK, server) case http.MethodDelete: serverID := r.URL.Query().Get("id") if serverID == "" { http.Error(w, "Missing id", http.StatusBadRequest) return } g.store.mu.Lock() server, exists := g.store.servers[serverID] if !exists || server.CommunityID != claims.CommunityID { g.store.mu.Unlock() http.Error(w, "Not found", http.StatusNotFound) return } delete(g.store.servers, serverID) hasMore := false for _, s := range g.store.servers { if s.CommunityID == claims.CommunityID { hasMore = true break } } g.store.mu.Unlock() if !hasMore { g.stopMock(claims.CommunityID) } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } // ============================================================================ // USER SEARCH // ============================================================================ func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) { claims, err := g.requireAuth(r) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) g.store.mu.RLock() defer g.store.mu.RUnlock() type userResult struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` Role string `json:"role"` } var results []userResult for _, u := range g.store.users { if u.CommunityID == claims.CommunityID && u.ID != claims.UserID { if query == "" || strings.Contains(strings.ToLower(u.Username), query) { results = append(results, userResult{ ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role, }) } } } if results == nil { results = []userResult{} } writeJSON(w, http.StatusOK, map[string]interface{}{"users": results}) } // ============================================================================ // PERMISSIONS // ============================================================================ func (g *Gateway) handlePermissions(w http.ResponseWriter, r *http.Request) { claims, err := g.requireAuth(r) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } switch r.Method { case http.MethodGet: userID := r.URL.Query().Get("userId") g.store.mu.RLock() var result []*DevPermission for _, p := range g.store.permissions { if p.CommunityID == claims.CommunityID { if userID == "" || p.UserID == userID { result = append(result, p) } } } g.store.mu.RUnlock() if result == nil { result = []*DevPermission{} } writeJSON(w, http.StatusOK, map[string]interface{}{"permissions": result}) case http.MethodPost: var req struct { UserID string `json:"userId"` ServerID string `json:"serverId"` Scopes []string `json:"scopes"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.UserID == "" { writeError(w, http.StatusBadRequest, "userId is required") return } g.store.mu.Lock() defer g.store.mu.Unlock() targetUser, exists := g.store.users[req.UserID] if !exists { writeError(w, http.StatusNotFound, "User not found") return } serverName := "All Servers" if req.ServerID != "" { if s, ok := g.store.servers[req.ServerID]; ok { serverName = s.Name } } // Upsert: update if same user+server combo exists for _, p := range g.store.permissions { if p.UserID == req.UserID && p.ServerID == req.ServerID && p.CommunityID == claims.CommunityID { p.Scopes = req.Scopes p.GrantedBy = claims.Username p.GrantedAt = time.Now() writeJSON(w, http.StatusOK, p) return } } perm := &DevPermission{ ID: uuid.NewString(), CommunityID: claims.CommunityID, UserID: req.UserID, Username: targetUser.Username, ServerID: req.ServerID, ServerName: serverName, Scopes: req.Scopes, GrantedBy: claims.Username, GrantedAt: time.Now(), } g.store.permissions[perm.ID] = perm writeJSON(w, http.StatusCreated, perm) case http.MethodDelete: permID := r.URL.Query().Get("id") if permID == "" { http.Error(w, "Missing id", http.StatusBadRequest) return } g.store.mu.Lock() defer g.store.mu.Unlock() perm, exists := g.store.permissions[permID] if !exists || perm.CommunityID != claims.CommunityID { http.Error(w, "Not found", http.StatusNotFound) return } delete(g.store.permissions, permID) writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } // ============================================================================ // PLAYER NOTES & WARNINGS // ============================================================================ func (g *Gateway) handlePlayerNotes(w http.ResponseWriter, r *http.Request) { claims, err := g.requireAuth(r) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } switch r.Method { case http.MethodGet: playerNameHash := r.URL.Query().Get("playerNameHash") if playerNameHash == "" { writeError(w, http.StatusBadRequest, "playerNameHash is required") return } g.store.mu.RLock() defer g.store.mu.RUnlock() var results []DevPlayerNote for _, note := range g.store.playerNotes { if note.CommunityID == claims.CommunityID && note.PlayerNameHash == playerNameHash { results = append(results, note) } } if results == nil { results = []DevPlayerNote{} } writeJSON(w, http.StatusOK, map[string]interface{}{"notes": results}) case http.MethodPost: var req struct { PlayerNameHash string `json:"playerNameHash"` Category string `json:"category"` Content string `json:"content"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } note := DevPlayerNote{ ID: uuid.NewString(), CommunityID: claims.CommunityID, PlayerNameHash: req.PlayerNameHash, Category: req.Category, Content: req.Content, CreatedBy: claims.Username, CreatedAt: time.Now(), } g.store.mu.Lock() g.store.playerNotes = append(g.store.playerNotes, note) g.store.mu.Unlock() writeJSON(w, http.StatusCreated, note) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func (g *Gateway) handlePlayers(w http.ResponseWriter, r *http.Request) { claims, err := g.requireAuth(r) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } g.store.mu.RLock() defer g.store.mu.RUnlock() type playerWithNotes struct { *DevPlayer WarningCount int `json:"warningCount"` } var result []playerWithNotes for _, p := range g.store.players { if p.CommunityID == claims.CommunityID { warnings := 0 for _, n := range g.store.playerNotes { if n.CommunityID == claims.CommunityID && n.PlayerNameHash == p.NameHash && n.Category == "warning" { warnings++ } } result = append(result, playerWithNotes{DevPlayer: p, WarningCount: warnings}) } } if result == nil { result = []playerWithNotes{} } writeJSON(w, http.StatusOK, map[string]interface{}{"players": result}) } func (g *Gateway) handleKick(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } claims, err := g.requireAuth(r) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } var req struct { PlayerNameHash string `json:"playerNameHash"` Reason string `json:"reason"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.PlayerNameHash == "" { writeError(w, http.StatusBadRequest, "playerNameHash is required") return } key := claims.CommunityID + ":" + req.PlayerNameHash g.store.mu.Lock() _, exists := g.store.players[key] if !exists { g.store.mu.Unlock() writeError(w, http.StatusNotFound, "Player not online") return } delete(g.store.players, key) g.store.mu.Unlock() // Broadcast a LEAVE event so the log stream shows the kick reason := req.Reason if reason == "" { reason = "Kicked by admin" } leaveEvent := &parser.LogEvent{ Type: "LEAVE", Content: "Player left the server [KICK: " + reason + "]", } g.broadcastEvent(leaveEvent, "server-123") log.Printf("[DEV] Kicked player %s (reason: %s) by %s", req.PlayerNameHash, reason, claims.Username) writeJSON(w, http.StatusOK, map[string]string{"status": "kicked", "playerNameHash": req.PlayerNameHash}) } func (g *Gateway) handleBan(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } claims, err := g.requireAuth(r) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } var req struct { PlayerNameHash string `json:"playerNameHash"` Data string `json:"data"` // Encrypted JSON DurationMinutes int `json:"durationMinutes"` // 0 = permanent } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.PlayerNameHash == "" { writeError(w, http.StatusBadRequest, "playerNameHash is required") return } var expiresAt *time.Time if req.DurationMinutes > 0 { t := time.Now().Add(time.Duration(req.DurationMinutes) * time.Minute) expiresAt = &t } ban := &DevBan{ ID: uuid.NewString(), CommunityID: claims.CommunityID, PlayerNameHash: req.PlayerNameHash, Data: req.Data, BannedAt: time.Now(), ExpiresAt: expiresAt, } g.store.mu.Lock() g.store.bans[ban.ID] = ban // Also remove from online players (kick) delete(g.store.players, claims.CommunityID+":"+req.PlayerNameHash) g.store.mu.Unlock() // Broadcast LEAVE event leaveEvent := &parser.LogEvent{ Type: "LEAVE", Content: "Player left the server [BAN]", } g.broadcastEvent(leaveEvent, "server-123") log.Printf("[DEV] Banned player %s (duration: %dmin) by %s", req.PlayerNameHash, req.DurationMinutes, claims.Username) writeJSON(w, http.StatusCreated, ban) } func (g *Gateway) handleBans(w http.ResponseWriter, r *http.Request) { claims, err := g.requireAuth(r) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } switch r.Method { case http.MethodGet: g.store.mu.RLock() var result []*DevBan for _, b := range g.store.bans { if b.CommunityID == claims.CommunityID { // Auto-expire if b.ExpiresAt == nil || b.ExpiresAt.After(time.Now()) { result = append(result, b) } } } g.store.mu.RUnlock() if result == nil { result = []*DevBan{} } writeJSON(w, http.StatusOK, map[string]interface{}{"bans": result}) case http.MethodDelete: banID := r.URL.Query().Get("id") if banID == "" { http.Error(w, "Missing id", http.StatusBadRequest) return } g.store.mu.Lock() ban, exists := g.store.bans[banID] if !exists || ban.CommunityID != claims.CommunityID { g.store.mu.Unlock() http.Error(w, "Not found", http.StatusNotFound) return } delete(g.store.bans, banID) g.store.mu.Unlock() writeJSON(w, http.StatusOK, map[string]string{"status": "unbanned"}) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } // ============================================================================ // REMAINING ENDPOINTS // ============================================================================ func (g *Gateway) handlePlayerSearch(w http.ResponseWriter, r *http.Request) { claims, err := g.requireAuth(r) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } g.store.mu.RLock() defer g.store.mu.RUnlock() type playerWithNotes struct { *DevPlayer WarningCount int `json:"warningCount"` } var result []playerWithNotes for _, p := range g.store.roster { if p.CommunityID == claims.CommunityID { warnings := 0 for _, n := range g.store.playerNotes { if n.CommunityID == claims.CommunityID && n.PlayerNameHash == p.NameHash && n.Category == "warning" { warnings++ } } result = append(result, playerWithNotes{DevPlayer: p, WarningCount: warnings}) } } if result == nil { result = []playerWithNotes{} } writeJSON(w, http.StatusOK, map[string]interface{}{"results": result}) } func (g *Gateway) handleLogs(w http.ResponseWriter, r *http.Request) { claims, err := g.requireAuth(r) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } g.store.mu.RLock() var result []LogEntry for _, entry := range g.store.logs { if entry.CommunityID == claims.CommunityID { result = append(result, entry) } } g.store.mu.RUnlock() if result == nil { result = []LogEntry{} } writeJSON(w, http.StatusOK, map[string]interface{}{"logs": result}) } func (g *Gateway) handleDSGVOExport(w http.ResponseWriter, r *http.Request) { playerID := r.URL.Query().Get("playerId") if playerID == "" { http.Error(w, "Missing playerId", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Disposition", "attachment; filename=player-data.json") json.NewEncoder(w).Encode(map[string]interface{}{ "playerId": playerID, "exportDate": time.Now().Format(time.RFC3339), "logs": []interface{}{}, }) } func (g *Gateway) handleDSGVODelete(w http.ResponseWriter, r *http.Request) { var req struct { PlayerID string `json:"playerId"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } func (g *Gateway) handleIngest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } apiKey := r.Header.Get("X-API-Key") if apiKey == "" { http.Error(w, "Missing API key", http.StatusUnauthorized) return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return } communityID := "comm-123-abc" g.natsClient.PublishLog(context.Background(), communityID, "api", body) w.WriteHeader(http.StatusAccepted) w.Write([]byte("OK")) } // ============================================================================ // MAIN // ============================================================================ func main() { natsURL := os.Getenv("NATS_URL") if natsURL == "" { natsURL = "nats://localhost:4222" } devMode := os.Getenv("DEV_MODE") == "true" if devMode { log.Println("[DEV] Development mode enabled — using in-memory store") } nc, err := internal_nats.Connect(natsURL) if err != nil { log.Fatalf("NATS error: %v", err) } defer nc.Close() nc.SetupStream(context.Background(), "LOGS", []string{"logs.>"}) gateway := &Gateway{ natsClient: nc, dashboards: make(map[*websocket.Conn]bool), devMode: devMode, store: newDevStore(), } go func() { nc.Conn.Subscribe("logs.>", func(m *nats.Msg) { gateway.broadcast(websocket.BinaryMessage, m.Data) }) nc.Conn.Subscribe("telemetry.>", func(m *nats.Msg) { gateway.broadcast(websocket.TextMessage, m.Data) }) }() http.HandleFunc("/ws", gateway.handleWebSocket) http.HandleFunc("/api/auth/register", gateway.handleRegister) http.HandleFunc("/api/auth/register/passkey/begin", gateway.handleRegisterPasskeyBegin) http.HandleFunc("/api/auth/register/passkey/finish", gateway.handleRegisterPasskeyFinish) http.HandleFunc("/api/auth/login/password", gateway.handleLoginPassword) http.HandleFunc("/api/auth/login/passkey/begin", gateway.handleLoginPasskeyBegin) http.HandleFunc("/api/auth/login/passkey/finish", gateway.handleLoginPasskeyFinish) http.HandleFunc("/api/auth/logout", gateway.handleLogout) http.HandleFunc("/api/auth/me", gateway.handleMe) http.HandleFunc("/api/auth/profile", gateway.handleProfile) http.HandleFunc("/api/auth/passkeys", gateway.handlePasskeys) http.HandleFunc("/api/auth/passkeys/begin", gateway.handleAddPasskeyBegin) http.HandleFunc("/api/auth/passkeys/finish", gateway.handleAddPasskeyFinish) go gateway.monitorNodes() http.HandleFunc("/api/nodes", gateway.handleNodes) http.HandleFunc("/ws/node", gateway.handleNodeWebSocket) if devMode { http.HandleFunc("/api/dev/nodes/ping", gateway.handleDevNodePing) } http.HandleFunc("/api/servers", gateway.handleServers) http.HandleFunc("/api/users/search", gateway.handleUserSearch) http.HandleFunc("/api/permissions", gateway.handlePermissions) http.HandleFunc("/api/players", gateway.handlePlayers) http.HandleFunc("/api/players/kick", gateway.handleKick) http.HandleFunc("/api/players/ban", gateway.handleBan) http.HandleFunc("/api/bans", gateway.handleBans) http.HandleFunc("/api/players/search", gateway.handlePlayerSearch) http.HandleFunc("/api/logs", gateway.handleLogs) http.HandleFunc("/api/dsgvo/export", gateway.handleDSGVOExport) http.HandleFunc("/api/dsgvo/delete", gateway.handleDSGVODelete) http.HandleFunc("/api/ingest", gateway.handleIngest) log.Println("Gateway listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) }