- Added `PlayerInsights` for detailed player data visualization and note management. - Added `PlayerContextMenu` for performing player actions like viewing insights, kick, and ban. - Refactored gateway response handling to use `writeError` for improved consistency. - Simplified community onboarding logic for new registrations, now using an empty community state.
2188 lines
61 KiB
Go
2188 lines
61 KiB
Go
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 {
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
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 {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
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 {
|
|
writeError(w, http.StatusInternalServerError, "Failed to hash password")
|
|
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
|
|
}
|
|
|
|
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
|
|
|
|
log.Printf("[DEV] Registered user: %s (Pending onboarding)", req.Username)
|
|
|
|
// Token with empty communityId indicates onboarding state
|
|
token, err := auth.GenerateJWT(userID, "", req.Username, 7*24*time.Hour)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"status": "success",
|
|
"token": token,
|
|
"userId": userID,
|
|
"communityId": "",
|
|
"username": req.Username,
|
|
"role": "",
|
|
"communities": []interface{}{},
|
|
"masterKey": "this-is-a-32-byte-master-key-xyz",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Non-dev mode fallback
|
|
userID := "user-" + req.Username
|
|
token, err := auth.GenerateJWT(userID, "", req.Username, 7*24*time.Hour)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"status": "success",
|
|
"token": token,
|
|
"userId": userID,
|
|
"communityId": "",
|
|
"username": req.Username,
|
|
"role": "",
|
|
"communities": []interface{}{},
|
|
"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 {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
return
|
|
}
|
|
|
|
options, err := webauthn.CreateRegistrationOptions(
|
|
"user-"+req.Username,
|
|
req.Username,
|
|
req.DisplayName,
|
|
"ArmaAdmin Zero-Knowledge Cloud",
|
|
"localhost",
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Failed to create options")
|
|
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:
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
}
|
|
}
|
|
|
|
func (g *Gateway) handleAddPasskeyBegin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
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 {
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
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 {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
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:
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
}
|
|
}
|
|
|
|
func (g *Gateway) handleNodeWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
token := r.URL.Query().Get("token")
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "Missing token")
|
|
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 {
|
|
writeError(w, http.StatusUnauthorized, "Invalid token")
|
|
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 {
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
if !g.devMode {
|
|
writeError(w, http.StatusNotFound, "Not found")
|
|
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 {
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
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 {
|
|
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
|
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 {
|
|
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"status": "success",
|
|
"token": token,
|
|
"userId": userID,
|
|
"communityId": communityID,
|
|
"username": req.Username,
|
|
"role": "owner",
|
|
"communities": []map[string]string{
|
|
{"id": communityID, "name": req.Username + "'s Team", "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 {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
return
|
|
}
|
|
|
|
options, err := webauthn.CreateAuthenticationOptions("localhost", []string{})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Failed to create options")
|
|
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 {
|
|
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
|
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 {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
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["email"] = user.Email
|
|
// Find role in current community
|
|
for _, m := range g.store.memberships {
|
|
if m.UserID == claims.UserID && m.CommunityID == claims.CommunityID {
|
|
resp["role"] = m.Role
|
|
break
|
|
}
|
|
}
|
|
}
|
|
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 {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
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
|
|
for _, m := range g.store.memberships {
|
|
if m.UserID == claims.UserID && m.CommunityID == claims.CommunityID {
|
|
resp["role"] = m.Role
|
|
break
|
|
}
|
|
}
|
|
}
|
|
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 {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
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 {
|
|
writeError(w, http.StatusNotFound, "User not found")
|
|
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 {
|
|
writeError(w, http.StatusInternalServerError, "Failed to hash password")
|
|
return
|
|
}
|
|
user.PasswordHash = hash
|
|
}
|
|
|
|
var role string
|
|
for _, m := range g.store.memberships {
|
|
if m.UserID == user.ID && m.CommunityID == claims.CommunityID {
|
|
role = m.Role
|
|
break
|
|
}
|
|
}
|
|
|
|
token, err := auth.GenerateJWT(user.ID, claims.CommunityID, user.Username, 7*24*time.Hour)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Failed to generate token")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"status": "updated",
|
|
"token": token,
|
|
"username": user.Username,
|
|
"email": user.Email,
|
|
"role": role,
|
|
"communityId": claims.CommunityID,
|
|
})
|
|
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// SERVER MANAGEMENT
|
|
// ============================================================================
|
|
|
|
// ============================================================================
|
|
// COMMUNITIES
|
|
// ============================================================================
|
|
|
|
func (g *Gateway) handleCreateCommunity(w http.ResponseWriter, r *http.Request) {
|
|
claims, err := g.requireAuth(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
return
|
|
}
|
|
|
|
communityName := strings.TrimSpace(req.Name)
|
|
if communityName == "" {
|
|
writeError(w, http.StatusBadRequest, "Community name is required")
|
|
return
|
|
}
|
|
|
|
g.store.mu.Lock()
|
|
defer g.store.mu.Unlock()
|
|
|
|
communityID := uuid.NewString()
|
|
community := &DevCommunity{
|
|
ID: communityID,
|
|
Name: communityName,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
g.store.communities[communityID] = community
|
|
|
|
membership := DevMembership{
|
|
UserID: claims.UserID,
|
|
CommunityID: communityID,
|
|
Role: "owner",
|
|
}
|
|
g.store.memberships = append(g.store.memberships, membership)
|
|
|
|
log.Printf("[DEV] User %s created community: %s", claims.Username, communityName)
|
|
|
|
// Issue a new token for the new community
|
|
token, _ := auth.GenerateJWT(claims.UserID, communityID, claims.Username, 7*24*time.Hour)
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
|
"token": token,
|
|
"communityId": communityID,
|
|
"name": communityName,
|
|
"role": "owner",
|
|
})
|
|
}
|
|
|
|
func (g *Gateway) handleJoinCommunity(w http.ResponseWriter, r *http.Request) {
|
|
claims, err := g.requireAuth(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
CommunityID string `json:"communityId"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
return
|
|
}
|
|
|
|
g.store.mu.Lock()
|
|
defer g.store.mu.Unlock()
|
|
|
|
community, exists := g.store.communities[req.CommunityID]
|
|
if !exists {
|
|
writeError(w, http.StatusNotFound, "Community not found")
|
|
return
|
|
}
|
|
|
|
// Check if already a member
|
|
for _, m := range g.store.memberships {
|
|
if m.UserID == claims.UserID && m.CommunityID == req.CommunityID {
|
|
writeError(w, http.StatusConflict, "Already a member")
|
|
return
|
|
}
|
|
}
|
|
|
|
membership := DevMembership{
|
|
UserID: claims.UserID,
|
|
CommunityID: req.CommunityID,
|
|
Role: "member",
|
|
}
|
|
g.store.memberships = append(g.store.memberships, membership)
|
|
|
|
log.Printf("[DEV] User %s joined community: %s", claims.Username, community.Name)
|
|
|
|
token, _ := auth.GenerateJWT(claims.UserID, req.CommunityID, claims.Username, 7*24*time.Hour)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"token": token,
|
|
"communityId": req.CommunityID,
|
|
"name": community.Name,
|
|
"role": "member",
|
|
})
|
|
}
|
|
|
|
func (g *Gateway) handleServers(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 []*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 {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
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 {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
return
|
|
}
|
|
|
|
g.store.mu.Lock()
|
|
defer g.store.mu.Unlock()
|
|
|
|
server, exists := g.store.servers[req.ID]
|
|
if !exists || server.CommunityID != claims.CommunityID {
|
|
writeError(w, http.StatusNotFound, "Server not found")
|
|
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 == "" {
|
|
writeError(w, http.StatusBadRequest, "Missing id")
|
|
return
|
|
}
|
|
|
|
g.store.mu.Lock()
|
|
server, exists := g.store.servers[serverID]
|
|
if !exists || server.CommunityID != claims.CommunityID {
|
|
g.store.mu.Unlock()
|
|
writeError(w, http.StatusNotFound, "Not found")
|
|
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:
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// USER SEARCH
|
|
// ============================================================================
|
|
|
|
func (g *Gateway) handleUserSearch(w http.ResponseWriter, r *http.Request) {
|
|
claims, err := g.requireAuth(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
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 _, m := range g.store.memberships {
|
|
if m.CommunityID == claims.CommunityID && m.UserID != claims.UserID {
|
|
if u, ok := g.store.users[m.UserID]; ok {
|
|
if query == "" || strings.Contains(strings.ToLower(u.Username), query) {
|
|
results = append(results, userResult{
|
|
ID: u.ID,
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
Role: m.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 {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
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 {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
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 == "" {
|
|
writeError(w, http.StatusBadRequest, "Missing id")
|
|
return
|
|
}
|
|
g.store.mu.Lock()
|
|
defer g.store.mu.Unlock()
|
|
perm, exists := g.store.permissions[permID]
|
|
if !exists || perm.CommunityID != claims.CommunityID {
|
|
writeError(w, http.StatusNotFound, "Not found")
|
|
return
|
|
}
|
|
delete(g.store.permissions, permID)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
|
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// PLAYER NOTES & WARNINGS
|
|
// ============================================================================
|
|
|
|
func (g *Gateway) handlePlayerNotes(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:
|
|
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 {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
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:
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
}
|
|
}
|
|
|
|
func (g *Gateway) handlePlayers(w http.ResponseWriter, r *http.Request) {
|
|
claims, err := g.requireAuth(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
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 {
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
claims, err := g.requireAuth(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
PlayerNameHash string `json:"playerNameHash"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
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 {
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
claims, err := g.requireAuth(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
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 {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
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 {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
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 == "" {
|
|
writeError(w, http.StatusBadRequest, "Missing id")
|
|
return
|
|
}
|
|
g.store.mu.Lock()
|
|
ban, exists := g.store.bans[banID]
|
|
if !exists || ban.CommunityID != claims.CommunityID {
|
|
g.store.mu.Unlock()
|
|
writeError(w, http.StatusNotFound, "Not found")
|
|
return
|
|
}
|
|
delete(g.store.bans, banID)
|
|
g.store.mu.Unlock()
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "unbanned"})
|
|
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// REMAINING ENDPOINTS
|
|
// ============================================================================
|
|
|
|
func (g *Gateway) handlePlayerSearch(w http.ResponseWriter, r *http.Request) {
|
|
claims, err := g.requireAuth(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
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 {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
|
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 == "" {
|
|
writeError(w, http.StatusBadRequest, "Missing playerId")
|
|
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 {
|
|
writeError(w, http.StatusBadRequest, "Invalid request")
|
|
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 {
|
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
apiKey := r.Header.Get("X-API-Key")
|
|
if apiKey == "" {
|
|
writeError(w, http.StatusUnauthorized, "Missing API key")
|
|
return
|
|
}
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "Failed to read body")
|
|
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/communities/create", gateway.handleCreateCommunity)
|
|
http.HandleFunc("/api/communities/join", gateway.handleJoinCommunity)
|
|
|
|
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))
|
|
}
|