From 3db298ec3eca0ed94cb7912f660df7dd1f4582e0 Mon Sep 17 00:00:00 2001
From: plutorocks <>
Date: Thu, 26 Feb 2026 21:30:32 +0000
Subject: initial commit
---
.gitignore | 39 +++++++++++++
admin_auih.go | 44 +++++++++++++++
admin_handlers.go | 135 ++++++++++++++++++++++++++++++++++++++++++++
admin_page.go | 152 +++++++++++++++++++++++++++++++++++++++++++++++++
client.go | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
events.go | 49 ++++++++++++++++
go.mod | 5 ++
go.sum | 2 +
helpers.go | 17 ++++++
hub.go | 86 ++++++++++++++++++++++++++++
limits.go | 7 +++
main.go | 42 ++++++++++++++
messages.go | 46 +++++++++++++++
tokens.go | 59 +++++++++++++++++++
ws.go | 106 ++++++++++++++++++++++++++++++++++
15 files changed, 955 insertions(+)
create mode 100644 .gitignore
create mode 100644 admin_auih.go
create mode 100644 admin_handlers.go
create mode 100644 admin_page.go
create mode 100644 client.go
create mode 100644 events.go
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 helpers.go
create mode 100644 hub.go
create mode 100644 limits.go
create mode 100644 main.go
create mode 100644 messages.go
create mode 100644 tokens.go
create mode 100644 ws.go
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 = `
+
+
+
+ Bridge Admin
+
+
+
+ Bridge Admin
+
+
+
+
+
Tokens
+
+
+
+ | Name | Token | Revoked | Actions |
+
+
+
+
+
+
+
Events (Chat + Location)
+
+
+
+
+
+`
+
+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
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..e2017d5
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module bridge.plutorocks.dev/bridge-backend
+
+go 1.22
+
+require github.com/gorilla/websocket v1.5.3
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..25a9fc4
--- /dev/null
+++ b/go.sum
@@ -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
diff --git a/hub.go b/hub.go
new file mode 100644
index 0000000..366e4b8
--- /dev/null
+++ b/hub.go
@@ -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
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..85a4a54
--- /dev/null
+++ b/main.go
@@ -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
diff --git a/ws.go b/ws.go
new file mode 100644
index 0000000..aea8621
--- /dev/null
+++ b/ws.go
@@ -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
--
cgit v1.2.3