summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorplutorocks <>2026-02-26 21:30:32 +0000
committerplutorocks <>2026-02-26 21:30:32 +0000
commit3db298ec3eca0ed94cb7912f660df7dd1f4582e0 (patch)
treed406e1d64aa10c8027bf4bf4a5914f1378fb495e
initial commitHEADmaster
-rw-r--r--.gitignore39
-rw-r--r--admin_auih.go44
-rw-r--r--admin_handlers.go135
-rw-r--r--admin_page.go152
-rw-r--r--client.go166
-rw-r--r--events.go49
-rw-r--r--go.mod5
-rw-r--r--go.sum2
-rw-r--r--helpers.go17
-rw-r--r--hub.go86
-rw-r--r--limits.go7
-rw-r--r--main.go42
-rw-r--r--messages.go46
-rw-r--r--tokens.go59
-rw-r--r--ws.go106
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
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