diff options
| author | plutorocks <> | 2026-02-26 21:30:32 +0000 |
|---|---|---|
| committer | plutorocks <> | 2026-02-26 21:30:32 +0000 |
| commit | 3db298ec3eca0ed94cb7912f660df7dd1f4582e0 (patch) | |
| tree | d406e1d64aa10c8027bf4bf4a5914f1378fb495e | |
| -rw-r--r-- | .gitignore | 39 | ||||
| -rw-r--r-- | admin_auih.go | 44 | ||||
| -rw-r--r-- | admin_handlers.go | 135 | ||||
| -rw-r--r-- | admin_page.go | 152 | ||||
| -rw-r--r-- | client.go | 166 | ||||
| -rw-r--r-- | events.go | 49 | ||||
| -rw-r--r-- | go.mod | 5 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | helpers.go | 17 | ||||
| -rw-r--r-- | hub.go | 86 | ||||
| -rw-r--r-- | limits.go | 7 | ||||
| -rw-r--r-- | main.go | 42 | ||||
| -rw-r--r-- | messages.go | 46 | ||||
| -rw-r--r-- | tokens.go | 59 | ||||
| -rw-r--r-- | ws.go | 106 |
15 files changed, 955 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1aba02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# ===== Go build artifacts ===== +bridge-server +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out + +# Go workspace file (optional; commit only if you intentionally use it) +go.work + +# ===== Dependency / module cache (never commit) ===== +vendor/ + +# ===== Environment / secrets ===== +.env +.env.* +tokens.json +tokens.local.json + +# ===== Logs ===== +*.log +logs/ + +# ===== OS junk ===== +.DS_Store +Thumbs.db + +# ===== IDEs ===== +.idea/ +.vscode/ +*.swp +*.swo + +# ===== Coverage ===== +coverage.out +coverage.html
\ No newline at end of file diff --git a/admin_auih.go b/admin_auih.go new file mode 100644 index 0000000..7bb97aa --- /dev/null +++ b/admin_auih.go @@ -0,0 +1,44 @@ +// admin_auth.go +package main + +import ( + "net/http" + "os" +) + +type AdminAuth struct { + users map[string]string +} + +func newAdminAuthFromEnv() *AdminAuth { + users := make(map[string]string) + u1 := os.Getenv("ADMIN_USER") + p1 := os.Getenv("ADMIN_PASS") + if u1 != "" && p1 != "" { + users[u1] = p1 + } + u2 := os.Getenv("ADMIN_USER2") + p2 := os.Getenv("ADMIN_PASS2") + if u2 != "" && p2 != "" { + users[u2] = p2 + } + return &AdminAuth{users: users} +} + +func (a *AdminAuth) requireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok { + w.Header().Set("WWW-Authenticate", "Basic realm=\"bridge-admin\"") + w.WriteHeader(http.StatusUnauthorized) + return + } + expected, ok := a.users[user] + if !ok || expected != pass { + w.Header().Set("WWW-Authenticate", "Basic realm=\"bridge-admin\"") + w.WriteHeader(http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +}
\ No newline at end of file diff --git a/admin_handlers.go b/admin_handlers.go new file mode 100644 index 0000000..383cebe --- /dev/null +++ b/admin_handlers.go @@ -0,0 +1,135 @@ +// admin_handlers.go +package main + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + "time" + + "github.com/gorilla/websocket" +) + +type tokenCreateRequest struct { + Name string `json:"name"` +} + +type tokenCreateResponse struct { + Token string `json:"token"` + Name string `json:"name"` +} + +func adminTokensHandler(hub *Hub, tokensPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + hub.mu.RLock() + var list []TokenConfig + for _, tc := range hub.tokens { + list = append(list, *tc) + } + hub.mu.RUnlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(list) + case http.MethodPost: + var req tokenCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if req.Name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + token, err := generateToken() + if err != nil { + log.Printf("error generating token: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + tc := &TokenConfig{Token: token, Name: req.Name} + hub.mu.Lock() + hub.tokens[token] = tc + if err := saveTokens(tokensPath, hub.tokens); err != nil { + log.Printf("warning: failed to save tokens: %v", err) + } + hub.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tokenCreateResponse{Token: token, Name: req.Name}) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + } +} + +type tokenRevokeRequest struct { + Token string `json:"token"` +} + +func adminTokenRevokeHandler(hub *Hub, tokensPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var req tokenRevokeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if req.Token == "" { + http.Error(w, "token required", http.StatusBadRequest) + return + } + + var toKick []*Client + + hub.mu.Lock() + tc, ok := hub.tokens[req.Token] + if ok { + tc.Revoked = true + + // collect clients using this token so we can kick them after unlocking + for c := range hub.clients { + if c.token == tc { + toKick = append(toKick, c) + } + } + + if err := saveTokens(tokensPath, hub.tokens); err != nil { + log.Printf("warning: failed to save tokens: %v", err) + } + } + hub.mu.Unlock() + + // Kick all clients outside the lock to avoid deadlocks with unregister/readPump. + for _, c := range toKick { + log.Printf("kicking client %s (%s) due to token revoke", c.nick, c.token.Name) + _ = c.conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "token revoked"), + time.Now().Add(5*time.Second), + ) + c.conn.Close() + } + + w.WriteHeader(http.StatusNoContent) + } +} + +func adminEventsHandler(hub *Hub) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sinceStr := r.URL.Query().Get("since") + var since int64 + if sinceStr != "" { + if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil { + since = v + } + } + events := hub.getEventsSince(since) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(events) + } +}
\ No newline at end of file diff --git a/admin_page.go b/admin_page.go new file mode 100644 index 0000000..b132c1e --- /dev/null +++ b/admin_page.go @@ -0,0 +1,152 @@ +// admin_page.go +package main + +import "net/http" + +const adminHTML = `<!doctype html> +<html> +<head> + <meta charset="utf-8" /> + <title>Bridge Admin</title> + <style> + body { font-family: system-ui, sans-serif; margin: 1rem; background:#111; color:#eee; } + h1 { font-size: 1.4rem; } + .section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid #333; } + label { display:block; margin-bottom:0.25rem; } + input, button { padding:0.4rem 0.6rem; margin:0.1rem 0; background:#222; border:1px solid #555; color:#eee; border-radius:4px; } + button { cursor:pointer; } + table { border-collapse: collapse; width: 100%; font-size:0.9rem; } + th, td { border:1px solid #333; padding:0.25rem 0.4rem; } + th { background:#222; } + #log { background:#000; border:1px solid #333; padding:0.5rem; height:300px; overflow-y:scroll; font-family:monospace; font-size:0.85rem; } + .revoked { opacity:0.5; text-decoration:line-through; } + </style> +</head> +<body> + <h1>Bridge Admin</h1> + + <div class="section"> + <h2>New Token</h2> + <form id="newTokenForm"> + <label for="name">Internal name (e.g. jeff)</label> + <input id="name" name="name" required /> + <button type="submit">Create token</button> + </form> + <div id="newTokenOutput"></div> + </div> + + <div class="section"> + <h2>Tokens</h2> + <button onclick="loadTokens()">Reload tokens</button> + <table> + <thead> + <tr><th>Name</th><th>Token</th><th>Revoked</th><th>Actions</th></tr> + </thead> + <tbody id="tokensBody"></tbody> + </table> + </div> + + <div class="section"> + <h2>Events (Chat + Location)</h2> + <div id="log"></div> + </div> + +<script> +let lastEventId = 0; + +async function loadTokens() { + const res = await fetch('/admin/api/tokens'); + if (!res.ok) return; + const tokens = await res.json(); + const tbody = document.getElementById('tokensBody'); + tbody.innerHTML = ''; + tokens.forEach(t => { + const tr = document.createElement('tr'); + if (t.revoked) tr.classList.add('revoked'); + const tdName = document.createElement('td'); + tdName.textContent = t.name; + const tdToken = document.createElement('td'); + tdToken.textContent = t.token; + const tdRev = document.createElement('td'); + tdRev.textContent = t.revoked ? 'yes' : 'no'; + const tdAct = document.createElement('td'); + if (!t.revoked) { + const btn = document.createElement('button'); + btn.textContent = 'Revoke'; + btn.onclick = () => revokeToken(t.token); + tdAct.appendChild(btn); + } + tr.appendChild(tdName); + tr.appendChild(tdToken); + tr.appendChild(tdRev); + tr.appendChild(tdAct); + tbody.appendChild(tr); + }); +} + +async function revokeToken(token) { + if (!confirm('Revoke this token?')) return; + await fetch('/admin/api/tokens/revoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }) + }); + loadTokens(); +} + +const form = document.getElementById('newTokenForm'); +form.addEventListener('submit', async (e) => { + e.preventDefault(); + const name = document.getElementById('name').value.trim(); + if (!name) return; + const res = await fetch('/admin/api/tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + if (!res.ok) { + alert('Failed to create token'); + return; + } + const data = await res.json(); + document.getElementById('newTokenOutput').textContent = 'New token for ' + data.name + ': ' + data.token; + form.reset(); + loadTokens(); +}); + +async function pollEvents() { + try { + const res = await fetch('/admin/api/events?since=' + lastEventId); + if (!res.ok) return; + const events = await res.json(); + const logEl = document.getElementById('log'); + events.forEach(ev => { + if (ev.id > lastEventId) lastEventId = ev.id; + const line = document.createElement('div'); + const ts = new Date(ev.timestamp * 1000).toISOString(); + if (ev.type === 'chat') { + line.textContent = '[' + ts + '] [CHAT] ' + ev.fromNick + '(' + ev.fromInternal + '): ' + ev.message; + } else if (ev.type === 'location') { + line.textContent = '[' + ts + '] [LOC][' + ev.serverId + '] ' + ev.fromNick + '(' + ev.fromInternal + '): ' + + ' x=' + ev.x + ' y=' + ev.y + ' z=' + ev.z + ' dim=' + ev.dimension; + } else { + line.textContent = '[' + ts + '] [?] ' + JSON.stringify(ev); + } + logEl.appendChild(line); + logEl.scrollTop = logEl.scrollHeight; + }); + } catch (e) { + console.error('pollEvents error', e); + } +} + +loadTokens(); +setInterval(pollEvents, 2000); +</script> +</body> +</html>` + +func adminPageHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(adminHTML)) +}
\ No newline at end of file diff --git a/client.go b/client.go new file mode 100644 index 0000000..f9453b1 --- /dev/null +++ b/client.go @@ -0,0 +1,166 @@ +// client.go +package main + +import ( + "encoding/json" + "log" + "time" + "unicode/utf8" + + "github.com/gorilla/websocket" +) + +type Client struct { + hub *Hub + conn *websocket.Conn + send chan []byte + token *TokenConfig + nick string + serverID string + playerUUID string + lastSeen time.Time +} + +func (c *Client) readPump() { + defer func() { + c.hub.unregister(c) + c.conn.Close() + }() + + c.conn.SetReadLimit(4096) + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + + for { + _, data, err := c.conn.ReadMessage() + if err != nil { + break + } + + var msg IncomingMessage + if err := json.Unmarshal(data, &msg); err != nil { + log.Printf("invalid json from client: %v", err) + continue + } + + switch msg.Type { + case "chat": + c.handleChat(msg) + case "location": + c.handleLocation(msg) + case "nick": + c.handleNick(msg) + default: + log.Printf("unknown message type: %s", msg.Type) + } + } +} + +func (c *Client) writePump() { + ticker := time.NewTicker(30 * time.Second) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case msg, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if !ok { + // hub closed the channel + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-ticker.C: + // ping to keep connection alive + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func (c *Client) handleChat(msg IncomingMessage) { + if msg.Message == "" { + return + } + if utf8.RuneCountInString(msg.Message) > maxMessageLen { + log.Printf("dropping too-long chat message from %s (%s)", c.nick, c.token.Name) + return + } + + ts := time.Now().Unix() + out := OutChat{ + Type: "chat", + FromNick: c.nick, + FromInternal: c.token.Name, + Message: msg.Message, + Timestamp: ts, + } + data, _ := json.Marshal(out) + + // log event + c.hub.addEvent(Event{ + Type: "chat", + Timestamp: ts, + FromNick: c.nick, + FromInternal: c.token.Name, + Message: msg.Message, + }) + + // GLOBAL broadcast + c.hub.broadcastAll(data) +} + +func (c *Client) handleLocation(msg IncomingMessage) { + ts := time.Now().Unix() + out := OutLocation{ + Type: "location", + Nick: c.nick, + Internal: c.token.Name, + ServerID: c.serverID, + X: msg.X, + Y: msg.Y, + Z: msg.Z, + Dimension: msg.Dimension, + Timestamp: ts, + } + data, _ := json.Marshal(out) + + // log event + c.hub.addEvent(Event{ + Type: "location", + Timestamp: ts, + FromNick: c.nick, + FromInternal: c.token.Name, + ServerID: c.serverID, + X: msg.X, + Y: msg.Y, + Z: msg.Z, + Dimension: msg.Dimension, + }) + + // broadcast only to same server + c.hub.broadcastToServer(c.serverID, data) +} + +func (c *Client) handleNick(msg IncomingMessage) { + if msg.Nick == "" { + return + } + if utf8.RuneCountInString(msg.Nick) > maxNickLen { + log.Printf("rejecting nick > %d chars from %s (%s)", maxNickLen, c.nick, c.token.Name) + return + } + c.hub.mu.Lock() + c.nick = msg.Nick + c.hub.mu.Unlock() +}
\ No newline at end of file diff --git a/events.go b/events.go new file mode 100644 index 0000000..9800e39 --- /dev/null +++ b/events.go @@ -0,0 +1,49 @@ +// events.go +package main + +type Event struct { + ID int64 `json:"id"` + Type string `json:"type"` // "chat" or "location" + Timestamp int64 `json:"timestamp"` + FromNick string `json:"fromNick,omitempty"` + FromInternal string `json:"fromInternal,omitempty"` + Message string `json:"message,omitempty"` + ServerID string `json:"serverId,omitempty"` + X float64 `json:"x,omitempty"` + Y float64 `json:"y,omitempty"` + Z float64 `json:"z,omitempty"` + Dimension string `json:"dimension,omitempty"` +} + +func (h *Hub) addEvent(e Event) { + h.mu.Lock() + defer h.mu.Unlock() + + e.ID = h.nextEventID + h.nextEventID++ + + if len(h.events) >= h.maxEvents { + copy(h.events, h.events[1:]) + h.events[len(h.events)-1] = e + } else { + h.events = append(h.events, e) + } +} + +func (h *Hub) getEventsSince(since int64) []Event { + h.mu.RLock() + defer h.mu.RUnlock() + + if since <= 0 { + out := make([]Event, len(h.events)) + copy(out, h.events) + return out + } + var res []Event + for _, e := range h.events { + if e.ID > since { + res = append(res, e) + } + } + return res +}
\ No newline at end of file @@ -0,0 +1,5 @@ +module bridge.plutorocks.dev/bridge-backend + +go 1.22 + +require github.com/gorilla/websocket v1.5.3 @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..85f6113 --- /dev/null +++ b/helpers.go @@ -0,0 +1,17 @@ +// helpers.go +package main + +// truncateRunes returns at most n runes from s. +func truncateRunes(s string, n int) string { + if n <= 0 { + return "" + } + i := 0 + for idx := range s { + if i == n { + return s[:idx] + } + i++ + } + return s +}
\ No newline at end of file @@ -0,0 +1,86 @@ +// hub.go +package main + +import "sync" + +type Hub struct { + mu sync.RWMutex + clients map[*Client]bool + byServer map[string]map[*Client]bool + tokens map[string]*TokenConfig + + events []Event + nextEventID int64 + maxEvents int +} + +func NewHub(tokens map[string]*TokenConfig) *Hub { + return &Hub{ + clients: make(map[*Client]bool), + byServer: make(map[string]map[*Client]bool), + tokens: tokens, + events: make([]Event, 0, 1024), + nextEventID: 1, + maxEvents: 1024, + } +} + +func (h *Hub) register(c *Client) { + h.mu.Lock() + defer h.mu.Unlock() + + h.clients[c] = true + + if _, ok := h.byServer[c.serverID]; !ok { + h.byServer[c.serverID] = make(map[*Client]bool) + } + h.byServer[c.serverID][c] = true +} + +func (h *Hub) unregister(c *Client) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, ok := h.clients[c]; ok { + delete(h.clients, c) + } + if m, ok := h.byServer[c.serverID]; ok { + delete(m, c) + if len(m) == 0 { + delete(h.byServer, c.serverID) + } + } + close(c.send) +} + +func (h *Hub) broadcastToServer(serverID string, payload []byte) { + h.mu.RLock() + defer h.mu.RUnlock() + + clients := h.byServer[serverID] + for c := range clients { + select { + case c.send <- payload: + default: + // channel full -> drop client + go func(cc *Client) { + cc.conn.Close() + }(c) + } + } +} + +func (h *Hub) broadcastAll(payload []byte) { + h.mu.RLock() + defer h.mu.RUnlock() + + for c := range h.clients { + select { + case c.send <- payload: + default: + go func(cc *Client) { + cc.conn.Close() + }(c) + } + } +}
\ No newline at end of file diff --git a/limits.go b/limits.go new file mode 100644 index 0000000..a8b2566 --- /dev/null +++ b/limits.go @@ -0,0 +1,7 @@ +// limits.go +package main + +const ( + maxNickLen = 15 // maximum nickname length in runes + maxMessageLen = 400 // maximum chat message length in runes +)
\ No newline at end of file @@ -0,0 +1,42 @@ +// main.go +package main + +import ( + "log" + "net/http" + "os" +) + +func main() { + tokensPath := "tokens.json" + if v := os.Getenv("TOKENS_PATH"); v != "" { + tokensPath = v + } + + tokens, err := loadTokens(tokensPath) + if err != nil { + log.Fatalf("failed to load tokens from %s: %v", tokensPath, err) + } + log.Printf("loaded %d tokens", len(tokens)) + + hub := NewHub(tokens) + adminAuth := newAdminAuthFromEnv() + + http.HandleFunc("/ws", wsHandler(hub)) + + // admin routes (basic auth) + http.Handle("/admin", adminAuth.requireAdmin(http.HandlerFunc(adminPageHandler))) + http.Handle("/admin/", adminAuth.requireAdmin(http.HandlerFunc(adminPageHandler))) + http.Handle("/admin/api/tokens", adminAuth.requireAdmin(http.HandlerFunc(adminTokensHandler(hub, tokensPath)))) + http.Handle("/admin/api/tokens/revoke", adminAuth.requireAdmin(http.HandlerFunc(adminTokenRevokeHandler(hub, tokensPath)))) + http.Handle("/admin/api/events", adminAuth.requireAdmin(http.HandlerFunc(adminEventsHandler(hub)))) + + addr := ":8080" + if v := os.Getenv("BRIDGE_ADDR"); v != "" { + addr = v + } + log.Printf("bridge server listening on %s", addr) + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatalf("ListenAndServe: %v", err) + } +}
\ No newline at end of file diff --git a/messages.go b/messages.go new file mode 100644 index 0000000..805334d --- /dev/null +++ b/messages.go @@ -0,0 +1,46 @@ +// messages.go +package main + +type IncomingMessage struct { + Type string `json:"type"` // "auth", "chat", "location", "nick" + Token string `json:"token,omitempty"` + Nick string `json:"nick,omitempty"` + ServerID string `json:"serverId,omitempty"` + PlayerUUID string `json:"playerUuid,omitempty"` + + Message string `json:"message,omitempty"` + X float64 `json:"x,omitempty"` + Y float64 `json:"y,omitempty"` + Z float64 `json:"z,omitempty"` + Dimension string `json:"dimension,omitempty"` +} + +type AuthOK struct { + Type string `json:"type"` // "auth_ok" + InternalName string `json:"internalName"` +} + +type AuthError struct { + Type string `json:"type"` // "auth_error" + Error string `json:"error"` +} + +type OutChat struct { + Type string `json:"type"` // "chat" + FromNick string `json:"fromNick"` + FromInternal string `json:"fromInternal"` + Message string `json:"message"` + Timestamp int64 `json:"timestamp"` +} + +type OutLocation struct { + Type string `json:"type"` // "location" + Nick string `json:"nick"` + Internal string `json:"internal"` + ServerID string `json:"serverId"` + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` + Dimension string `json:"dimension"` + Timestamp int64 `json:"timestamp"` +}
\ No newline at end of file diff --git a/tokens.go b/tokens.go new file mode 100644 index 0000000..9736b9c --- /dev/null +++ b/tokens.go @@ -0,0 +1,59 @@ +// tokens.go +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "os" +) + +type TokenConfig struct { + Token string `json:"token"` // raw token; in real life you want a hash + Name string `json:"name"` // internal name ("jeff", etc.) + Revoked bool `json:"revoked,omitempty"` +} + +func loadTokens(path string) (map[string]*TokenConfig, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var list []TokenConfig + if err := json.NewDecoder(f).Decode(&list); err != nil { + return nil, err + } + + m := make(map[string]*TokenConfig) + for i := range list { + tc := list[i] + m[tc.Token] = &tc + } + return m, nil +} + +func saveTokens(path string, tokens map[string]*TokenConfig) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + var list []TokenConfig + for _, tc := range tokens { + list = append(list, *tc) + } + return enc.Encode(list) +} + +func generateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "brg_" + base64.RawURLEncoding.EncodeToString(b), nil +}
\ No newline at end of file @@ -0,0 +1,106 @@ +// ws.go +package main + +import ( + "encoding/json" + "log" + "net/http" + "time" + "unicode/utf8" + + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // okay while you're behind nginx/caddy + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func wsHandler(hub *Hub) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("upgrade error: %v", err) + return + } + + // Expect first message to be auth + _, firstMsg, err := conn.ReadMessage() + if err != nil { + log.Printf("failed to read auth message: %v", err) + conn.Close() + return + } + + var auth IncomingMessage + if err := json.Unmarshal(firstMsg, &auth); err != nil { + log.Printf("invalid auth json: %v", err) + _ = conn.WriteJSON(AuthError{Type: "auth_error", Error: "bad_json"}) + conn.Close() + return + } + + if auth.Type != "auth" || auth.Token == "" { + _ = conn.WriteJSON(AuthError{Type: "auth_error", Error: "missing_token"}) + conn.Close() + return + } + + // Lookup token + hub.mu.RLock() + tc, ok := hub.tokens[auth.Token] + hub.mu.RUnlock() + + if !ok || tc.Revoked { + _ = conn.WriteJSON(AuthError{Type: "auth_error", Error: "invalid_token"}) + conn.Close() + return + } + + if auth.ServerID == "" { + auth.ServerID = "default" + } + if auth.Nick == "" { + auth.Nick = tc.Name + } + + // Enforce nickname length limit on initial/auth nick. + if utf8.RuneCountInString(auth.Nick) > maxNickLen { + log.Printf("auth nick %q too long, falling back to internal name %q", auth.Nick, tc.Name) + auth.Nick = tc.Name + } + if utf8.RuneCountInString(auth.Nick) > maxNickLen { + // still too long, truncate internal name + log.Printf("internal name %q too long, truncating to %d runes", auth.Nick, maxNickLen) + auth.Nick = truncateRunes(auth.Nick, maxNickLen) + } + + client := &Client{ + hub: hub, + conn: conn, + send: make(chan []byte, 32), + token: tc, + nick: auth.Nick, + serverID: auth.ServerID, + playerUUID: auth.PlayerUUID, + lastSeen: time.Now(), + } + + hub.register(client) + + if err := conn.WriteJSON(AuthOK{ + Type: "auth_ok", + InternalName: tc.Name, + }); err != nil { + log.Printf("failed to send auth_ok: %v", err) + hub.unregister(client) + conn.Close() + return + } + + go client.writePump() + client.readPump() + } +}
\ No newline at end of file |
