summaryrefslogtreecommitdiff
path: root/api
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
committerElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
commitfb653292612eddca5b088ed88466c546d1597107 (patch)
tree900ed32e066812688858ed3bb15128c41bb78054 /api
downloadpenguins.lan-fb653292612eddca5b088ed88466c546d1597107.tar.gz
penguins.lan-fb653292612eddca5b088ed88466c546d1597107.zip
Initial commit
Diffstat (limited to 'api')
-rw-r--r--api/auth/auth.go280
-rw-r--r--api/canvas/canvas.go373
-rw-r--r--api/canvas/canvas_test.go83
-rw-r--r--api/minecraft/minecraft.go82
-rw-r--r--api/minecraft/minecraft_test.go40
-rw-r--r--api/presence/presence.go64
-rw-r--r--api/serve.go165
-rw-r--r--api/template/template.go77
-rw-r--r--api/types/types.go37
-rw-r--r--api/ws/ws.go293
10 files changed, 1494 insertions, 0 deletions
diff --git a/api/auth/auth.go b/api/auth/auth.go
new file mode 100644
index 0000000..a70af32
--- /dev/null
+++ b/api/auth/auth.go
@@ -0,0 +1,280 @@
+package auth
+
+import (
+ "errors"
+ "log"
+ "net"
+ "net/http"
+ "time"
+
+ "penguins.lan/adapters/arp"
+ "penguins.lan/api/types"
+ "penguins.lan/database"
+ "penguins.lan/utils"
+)
+
+const (
+ sessionCookie = "penguin_session"
+ sessionTTL = 30 * 24 * time.Hour
+)
+
+func ClientIP(req *http.Request) string {
+ host, _, err := net.SplitHostPort(req.RemoteAddr)
+ if err != nil {
+ return req.RemoteAddr
+ }
+ return host
+}
+
+// clientMac resolves the visitor's MAC from the DEV_MAC override or the ARP
+// cache the scheduler keeps fresh. "" means we don't know it.
+func clientMac(context *types.RequestContext, req *http.Request) string {
+ if dev := context.Args.DevMAC; dev != "" {
+ if mac, err := utils.ParseMac(dev); err == nil {
+ return mac.Format()
+ }
+ return ""
+ }
+ ip := ClientIP(req)
+ mac, err := database.MacForIP(context.DBConn, ip)
+ if err != nil {
+ log.Println("mac lookup failed:", err)
+ }
+ if mac != "" {
+ return mac
+ }
+ return liveMac(ip)
+}
+
+// liveMac falls back to a direct ARP read for a brand-new connection the
+// scheduler hasn't cached yet. Non-IPv4 peers (e.g. loopback) are skipped so we
+// don't shell out for addresses that can never resolve.
+func liveMac(ip string) string {
+ if _, err := utils.ParseINet4(ip); err != nil {
+ return ""
+ }
+ table, err := arp.SystemProvider{}.Fetch()
+ if err != nil {
+ log.Println("live arp lookup failed:", err)
+ return ""
+ }
+ mac, _ := table.Lookup(ip)
+ return mac
+}
+
+func setSessionCookie(resp http.ResponseWriter, id string) {
+ http.SetCookie(resp, &http.Cookie{
+ Name: sessionCookie,
+ Value: id,
+ Path: "/",
+ MaxAge: int(sessionTTL.Seconds()),
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func clearSessionCookie(resp http.ResponseWriter) {
+ http.SetCookie(resp, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
+}
+
+func LoginUser(context *types.RequestContext, resp http.ResponseWriter, user *database.User) error {
+ session, err := database.CreateSession(context.DBConn, utils.RandomId(), user.ID, sessionTTL)
+ if err != nil {
+ return err
+ }
+ setSessionCookie(resp, session.ID)
+ context.User = user
+ context.Nick = user.Username
+ return nil
+}
+
+func ResolveSessionContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ if cookie, err := req.Cookie(sessionCookie); err == nil && cookie.Value != "" {
+ if user, err := database.GetSessionUser(context.DBConn, cookie.Value); err == nil {
+ context.User = user
+ context.Nick = user.Username
+ _ = database.TouchUser(context.DBConn, user.ID)
+ return success(context, req, resp)
+ }
+ }
+
+ mac := clientMac(context, req)
+ context.ClientMAC = mac
+
+ if mac != "" {
+ if user, err := database.GetUserByMAC(context.DBConn, mac); err == nil {
+ if err := LoginUser(context, resp, user); err != nil {
+ log.Println("mac auto-login failed:", err)
+ } else {
+ _ = database.TouchUser(context.DBConn, user.ID)
+ }
+ }
+ }
+
+ return success(context, req, resp)
+ }
+}
+
+func RenameContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ if context.User == nil {
+ return success(context, req, resp)
+ }
+
+ newName := utils.SanitizeName(req.FormValue("username"))
+ if newName == "" || newName == context.User.Username {
+ http.Redirect(resp, req, "/", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+
+ renameError := func(message string) types.ContinuationChain {
+ (*context.TemplateData)["Error"] = types.BannerMessages{Messages: []string{message}}
+ return failure(context, req, resp)
+ }
+
+ switch existing, err := database.GetUserByUsername(context.DBConn, newName); {
+ case err == nil && existing.ID != context.User.ID:
+ return renameError("that handle's taken.")
+ case err != nil && !errors.Is(err, database.ErrNotFound):
+ log.Println("rename lookup failed:", err)
+ return renameError("something broke, try again")
+ }
+
+ if err := database.RenameUser(context.DBConn, context.User.ID, newName); err != nil {
+ log.Println("rename failed:", err)
+ return renameError("couldn't rename, try again")
+ }
+
+ context.User.Username = newName
+ context.Nick = newName
+ http.Redirect(resp, req, "/", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+}
+
+func RequireUserContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ if context.User != nil {
+ return success(context, req, resp)
+ }
+ return failure(context, req, resp)
+ }
+}
+
+func GoPortalContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ http.Redirect(resp, req, "/portal", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+}
+
+func UnauthorizedContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ resp.WriteHeader(http.StatusUnauthorized)
+ return success(context, req, resp)
+ }
+}
+
+func LogoutContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ if cookie, err := req.Cookie(sessionCookie); err == nil && cookie.Value != "" {
+ _ = database.DeleteSession(context.DBConn, cookie.Value)
+ }
+ clearSessionCookie(resp)
+ http.Redirect(resp, req, "/portal", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+}
+
+type portalView struct {
+ SuggestedName string
+ RequestedName string
+ DetectedMAC string
+ Taken bool
+ LastSeen string
+}
+
+func ShowPortalContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ if context.User != nil {
+ http.Redirect(resp, req, "/", http.StatusSeeOther)
+ return failure(context, req, resp)
+ }
+ (*context.TemplateData)["Portal"] = portalView{
+ SuggestedName: utils.SuggestName(),
+ DetectedMAC: context.ClientMAC,
+ }
+ return success(context, req, resp)
+ }
+}
+
+func SetupUserContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ username := utils.SanitizeName(req.FormValue("username"))
+ reclaim := req.FormValue("reclaim") == "1"
+
+ mac := clientMac(context, req)
+ context.ClientMAC = mac
+
+ view := portalView{
+ SuggestedName: utils.SuggestName(),
+ RequestedName: username,
+ DetectedMAC: mac,
+ }
+
+ fail := func(message string) types.ContinuationChain {
+ if message != "" {
+ (*context.TemplateData)["Error"] = types.BannerMessages{Messages: []string{message}}
+ }
+ (*context.TemplateData)["Portal"] = view
+ return failure(context, req, resp)
+ }
+
+ if username == "" {
+ resp.WriteHeader(http.StatusBadRequest)
+ return fail("pick a handle, any handle 🐧")
+ }
+
+ existing, err := database.GetUserByUsername(context.DBConn, username)
+ switch {
+ case errors.Is(err, database.ErrNotFound):
+ user, err := database.CreateUser(context.DBConn, utils.RandomId(), username)
+ if err != nil {
+ log.Println("create user failed:", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return fail("couldn't create your account, try again")
+ }
+ bindAndFinish(context, resp, user, mac)
+ http.Redirect(resp, req, "/", http.StatusSeeOther)
+ return success(context, req, resp)
+
+ case err != nil:
+ log.Println("lookup user failed:", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return fail("something broke, try again")
+
+ default:
+ if !reclaim {
+ view.Taken = true
+ view.LastSeen = utils.FormatSince(existing.LastSeen)
+ return fail("")
+ }
+ bindAndFinish(context, resp, existing, mac)
+ http.Redirect(resp, req, "/", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+ }
+}
+
+func bindAndFinish(context *types.RequestContext, resp http.ResponseWriter, user *database.User, mac string) {
+ if mac != "" {
+ if err := database.AssociateMAC(context.DBConn, mac, user.ID); err != nil {
+ log.Println("associate mac failed:", err)
+ }
+ }
+ _ = database.TouchUser(context.DBConn, user.ID)
+ if err := LoginUser(context, resp, user); err != nil {
+ log.Println("login failed:", err)
+ }
+}
diff --git a/api/canvas/canvas.go b/api/canvas/canvas.go
new file mode 100644
index 0000000..717d924
--- /dev/null
+++ b/api/canvas/canvas.go
@@ -0,0 +1,373 @@
+// Package canvas is a single shared r/place-style grid. The append-only pixel
+// log in the database is the source of truth; periodic binary snapshots let a
+// new client load the current state without replaying all of history.
+package canvas
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/gorilla/websocket"
+
+ "penguins.lan/api/types"
+ "penguins.lan/database"
+)
+
+const (
+ Rows = 64
+ Cols = 64
+ Scale = 8
+
+ placeCooldown = 2 * time.Second
+ snapshotThreshold = 50
+ defaultColor = 0xffffff
+
+ writeWait = 10 * time.Second
+ pongWait = 60 * time.Second
+ pingPeriod = (pongWait * 9) / 10
+ maxMessageSize = 256
+)
+
+// r/place 2017 palette.
+var palette = []int{
+ 0xffffff, 0xe4e4e4, 0x888888, 0x222222,
+ 0xffa7d1, 0xe50000, 0xe59500, 0xa06a42,
+ 0xe5d900, 0x94e044, 0x02be01, 0x00d3dd,
+ 0x0083c7, 0x0000ea, 0xcf6ee4, 0x820080,
+}
+
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+type inbound struct {
+ Type string `json:"type"`
+ X int `json:"x"`
+ Y int `json:"y"`
+ I int `json:"i"`
+}
+
+type outbound struct {
+ Type string `json:"type"`
+ X int `json:"x"`
+ Y int `json:"y"`
+ C int `json:"c"`
+ Ms int64 `json:"ms,omitempty"`
+}
+
+// ---- snapshot / state ------------------------------------------------------
+
+func blankCanvas() []byte {
+ buf := make([]byte, Rows*Cols*3)
+ for i := 0; i < len(buf); i += 3 {
+ buf[i] = byte(defaultColor >> 16)
+ buf[i+1] = byte((defaultColor >> 8) & 0xff)
+ buf[i+2] = byte(defaultColor & 0xff)
+ }
+ return buf
+}
+
+func setPixel(buf []byte, x, y, color int) {
+ if x < 0 || x >= Cols || y < 0 || y >= Rows {
+ return
+ }
+ i := (y*Cols + x) * 3
+ buf[i] = byte(color >> 16)
+ buf[i+1] = byte(color >> 8)
+ buf[i+2] = byte(color)
+}
+
+// baseline returns the latest snapshot's bytes and through-id, or a blank canvas.
+func baseline(db *sql.DB) ([]byte, int64, error) {
+ snap, err := database.LatestCanvasSnapshot(db)
+ if err != nil {
+ return nil, 0, err
+ }
+ if snap != nil && len(snap.Data) == Rows*Cols*3 {
+ buf := make([]byte, len(snap.Data))
+ copy(buf, snap.Data)
+ return buf, snap.ThroughID, nil
+ }
+ return blankCanvas(), 0, nil
+}
+
+func currentCanvas(db *sql.DB) ([]byte, error) {
+ buf, through, err := baseline(db)
+ if err != nil {
+ return nil, err
+ }
+ pixels, err := database.PixelsAfter(db, through)
+ if err != nil {
+ return nil, err
+ }
+ for _, p := range pixels {
+ setPixel(buf, p.X, p.Y, p.Color)
+ }
+ return buf, nil
+}
+
+func takeSnapshot(db *sql.DB) error {
+ buf, through, err := baseline(db)
+ if err != nil {
+ return err
+ }
+ pixels, err := database.PixelsAfter(db, through)
+ if err != nil {
+ return err
+ }
+ if len(pixels) == 0 {
+ return nil
+ }
+
+ maxID := through
+ for _, p := range pixels {
+ setPixel(buf, p.X, p.Y, p.Color)
+ if p.ID > maxID {
+ maxID = p.ID
+ }
+ }
+ if err := database.SaveCanvasSnapshot(db, buf, maxID); err != nil {
+ return err
+ }
+ return database.DeleteOldCanvasSnapshots(db)
+}
+
+// ---- hub -------------------------------------------------------------------
+
+type Hub struct {
+ db *sql.DB
+ clients map[*Client]bool
+ register chan *Client
+ unregister chan *Client
+ broadcast chan []byte
+ place chan placement
+ lastPlaced map[string]time.Time
+ sinceSnapshot int
+}
+
+type placement struct {
+ client *Client
+ x, y, i int
+}
+
+func NewHub(db *sql.DB) *Hub {
+ return &Hub{
+ db: db,
+ clients: make(map[*Client]bool),
+ register: make(chan *Client),
+ unregister: make(chan *Client),
+ broadcast: make(chan []byte),
+ place: make(chan placement),
+ lastPlaced: make(map[string]time.Time),
+ }
+}
+
+func (h *Hub) Run() {
+ if _, through, err := baseline(h.db); err == nil {
+ if n, err := database.CountPixelsAfter(h.db, through); err == nil {
+ h.sinceSnapshot = n
+ }
+ }
+
+ for {
+ select {
+ case client := <-h.register:
+ h.clients[client] = true
+ case client := <-h.unregister:
+ if _, ok := h.clients[client]; ok {
+ delete(h.clients, client)
+ close(client.send)
+ }
+ case payload := <-h.broadcast:
+ h.deliver(payload)
+ case p := <-h.place:
+ h.handlePlace(p)
+ }
+ }
+}
+
+func (h *Hub) deliver(payload []byte) {
+ for client := range h.clients {
+ select {
+ case client.send <- payload:
+ default:
+ delete(h.clients, client)
+ close(client.send)
+ }
+ }
+}
+
+func (h *Hub) sendTo(client *Client, msg outbound) {
+ if payload, err := json.Marshal(msg); err == nil {
+ select {
+ case client.send <- payload:
+ default:
+ }
+ }
+}
+
+func (h *Hub) handlePlace(p placement) {
+ if p.x < 0 || p.x >= Cols || p.y < 0 || p.y >= Rows || p.i < 0 || p.i >= len(palette) {
+ return
+ }
+
+ now := time.Now()
+ if last, ok := h.lastPlaced[p.client.userID]; ok {
+ if remaining := placeCooldown - now.Sub(last); remaining > 0 {
+ h.sendTo(p.client, outbound{Type: "cooldown", Ms: remaining.Milliseconds()})
+ return
+ }
+ }
+
+ color := palette[p.i]
+ if _, err := database.SavePixel(h.db, p.client.userID, p.x, p.y, color); err != nil {
+ log.Println("canvas: save pixel failed:", err)
+ return
+ }
+ h.lastPlaced[p.client.userID] = now
+
+ if payload, err := json.Marshal(outbound{Type: "place", X: p.x, Y: p.y, C: color}); err == nil {
+ h.deliver(payload)
+ }
+
+ h.sinceSnapshot++
+ if h.sinceSnapshot >= snapshotThreshold {
+ if err := takeSnapshot(h.db); err != nil {
+ log.Println("canvas: snapshot failed:", err)
+ } else {
+ h.sinceSnapshot = 0
+ }
+ }
+}
+
+// ---- client ----------------------------------------------------------------
+
+type Client struct {
+ hub *Hub
+ conn *websocket.Conn
+ send chan []byte
+ userID string
+}
+
+func (c *Client) readPump() {
+ defer func() {
+ c.hub.unregister <- c
+ c.conn.Close()
+ }()
+
+ c.conn.SetReadLimit(maxMessageSize)
+ c.conn.SetReadDeadline(time.Now().Add(pongWait))
+ c.conn.SetPongHandler(func(string) error {
+ c.conn.SetReadDeadline(time.Now().Add(pongWait))
+ return nil
+ })
+
+ for {
+ _, raw, err := c.conn.ReadMessage()
+ if err != nil {
+ break
+ }
+ var in inbound
+ if json.Unmarshal(raw, &in) != nil || in.Type != "place" {
+ continue
+ }
+ c.hub.place <- placement{client: c, x: in.X, y: in.Y, i: in.I}
+ }
+}
+
+func (c *Client) writePump() {
+ ticker := time.NewTicker(pingPeriod)
+ defer func() {
+ ticker.Stop()
+ c.conn.Close()
+ }()
+
+ for {
+ select {
+ case payload, ok := <-c.send:
+ c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if !ok {
+ c.conn.WriteMessage(websocket.CloseMessage, []byte{})
+ return
+ }
+ if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
+ return
+ }
+ case <-ticker.C:
+ c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+ return
+ }
+ }
+ }
+}
+
+// ---- continuations ---------------------------------------------------------
+
+type pageConfig struct {
+ Rows int
+ Cols int
+ Scale int
+ CooldownMs int64
+ Palette []string
+}
+
+func paletteHex() []string {
+ out := make([]string, len(palette))
+ for i, c := range palette {
+ out[i] = fmt.Sprintf("#%06x", c)
+ }
+ return out
+}
+
+func PageContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ (*context.TemplateData)["Canvas"] = pageConfig{
+ Rows: Rows,
+ Cols: Cols,
+ Scale: Scale,
+ CooldownMs: placeCooldown.Milliseconds(),
+ Palette: paletteHex(),
+ }
+ return success(context, req, resp)
+ }
+}
+
+func StateContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ buf, err := currentCanvas(context.DBConn)
+ if err != nil {
+ log.Println("canvas: state failed:", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return success(context, req, resp)
+ }
+ resp.Header().Set("Content-Type", "application/octet-stream")
+ resp.Write(buf)
+ return success(context, req, resp)
+ }
+}
+
+func ServeWSContinuation(hub *Hub) types.Continuation {
+ return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ conn, err := upgrader.Upgrade(resp, req, nil)
+ if err != nil {
+ log.Println("canvas: upgrade failed", err)
+ return failure(context, req, resp)
+ }
+
+ client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256), userID: context.User.ID}
+ hub.register <- client
+ go client.writePump()
+ go client.readPump()
+
+ return success(context, req, resp)
+ }
+ }
+}
diff --git a/api/canvas/canvas_test.go b/api/canvas/canvas_test.go
new file mode 100644
index 0000000..324a3a1
--- /dev/null
+++ b/api/canvas/canvas_test.go
@@ -0,0 +1,83 @@
+package canvas
+
+import (
+ "database/sql"
+ "path/filepath"
+ "testing"
+
+ "penguins.lan/database"
+)
+
+func canvasTestDB(t *testing.T) *sql.DB {
+ db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "c.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { db.Close() })
+ if _, err := database.Migrate(db); err != nil {
+ t.Fatal(err)
+ }
+ return db
+}
+
+func pixelAt(buf []byte, x, y int) int {
+ i := (y*Cols + x) * 3
+ return int(buf[i])<<16 | int(buf[i+1])<<8 | int(buf[i+2])
+}
+
+func TestCurrentCanvasReplaysPixels(t *testing.T) {
+ db := canvasTestDB(t)
+
+ if _, err := database.SavePixel(db, "u", 1, 2, 0xe50000); err != nil {
+ t.Fatal(err)
+ }
+
+ buf, err := currentCanvas(db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := pixelAt(buf, 1, 2); got != 0xe50000 {
+ t.Errorf("placed pixel = %06x, want e50000", got)
+ }
+ if got := pixelAt(buf, 0, 0); got != defaultColor {
+ t.Errorf("untouched pixel = %06x, want white", got)
+ }
+}
+
+func TestSnapshotBakesThenReplays(t *testing.T) {
+ db := canvasTestDB(t)
+
+ if _, err := database.SavePixel(db, "u", 5, 5, 0x000000); err != nil {
+ t.Fatal(err)
+ }
+ if err := takeSnapshot(db); err != nil {
+ t.Fatal(err)
+ }
+ // a placement after the snapshot must be replayed on top of it
+ if _, err := database.SavePixel(db, "u", 6, 6, 0x02be01); err != nil {
+ t.Fatal(err)
+ }
+
+ buf, err := currentCanvas(db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := pixelAt(buf, 5, 5); got != 0x000000 {
+ t.Errorf("baked pixel = %06x, want black", got)
+ }
+ if got := pixelAt(buf, 6, 6); got != 0x02be01 {
+ t.Errorf("replayed pixel = %06x, want green", got)
+ }
+
+ // snapshots are kept to one row
+ if err := takeSnapshot(db); err != nil {
+ t.Fatal(err)
+ }
+ var n int
+ if err := db.QueryRow("SELECT COUNT(*) FROM canvas_snapshots").Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ if n != 1 {
+ t.Errorf("snapshot rows = %d, want 1", n)
+ }
+}
diff --git a/api/minecraft/minecraft.go b/api/minecraft/minecraft.go
new file mode 100644
index 0000000..16cc0f3
--- /dev/null
+++ b/api/minecraft/minecraft.go
@@ -0,0 +1,82 @@
+package minecraft
+
+import (
+ "encoding/json"
+ "io"
+ "log"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "penguins.lan/adapters/rcon"
+ "penguins.lan/api/types"
+ "penguins.lan/database"
+)
+
+const (
+ queryTimeout = 3 * time.Second
+ // staleAfter marks the cache offline if the scheduler stopped refreshing it.
+ staleAfter = 30 * time.Second
+)
+
+type Status struct {
+ Online bool `json:"online"`
+ Count int `json:"count"`
+ Max int `json:"max"`
+ Players []string `json:"players"`
+}
+
+var listPattern = regexp.MustCompile(`There are (\d+) of a max of (\d+) players online:?\s*(.*)`)
+
+// Query runs "list" over RCON. The scheduler calls this; requests read the cache.
+func Query(address, password string) (Status, error) {
+ client, err := rcon.Dial(address, password, queryTimeout)
+ if err != nil {
+ return Status{}, err
+ }
+ defer client.Close()
+
+ out, err := client.Execute("list")
+ if err != nil {
+ return Status{}, err
+ }
+ return parseList(out), nil
+}
+
+func parseList(out string) Status {
+ m := listPattern.FindStringSubmatch(strings.TrimSpace(out))
+ if m == nil {
+ return Status{Online: true}
+ }
+
+ count, _ := strconv.Atoi(m[1])
+ max, _ := strconv.Atoi(m[2])
+
+ players := []string{}
+ for _, p := range strings.Split(m[3], ",") {
+ if p = strings.TrimSpace(p); p != "" {
+ players = append(players, p)
+ }
+ }
+ return Status{Online: true, Count: count, Max: max, Players: players}
+}
+
+func StatusContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ resp.Header().Set("Content-Type", "application/json")
+
+ cached, updatedAt, err := database.GetMinecraftStatus(context.DBConn)
+ if err != nil {
+ log.Println("minecraft: read cache failed:", err)
+ }
+
+ if cached == "" || time.Since(updatedAt) > staleAfter {
+ json.NewEncoder(resp).Encode(Status{Online: false})
+ } else {
+ io.WriteString(resp, cached)
+ }
+ return success(context, req, resp)
+ }
+}
diff --git a/api/minecraft/minecraft_test.go b/api/minecraft/minecraft_test.go
new file mode 100644
index 0000000..0c89fc5
--- /dev/null
+++ b/api/minecraft/minecraft_test.go
@@ -0,0 +1,40 @@
+package minecraft
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestParseList(t *testing.T) {
+ cases := []struct {
+ out string
+ want Status
+ }{
+ {
+ "There are 2 of a max of 20 players online: Alice, Bob",
+ Status{Online: true, Count: 2, Max: 20, Players: []string{"Alice", "Bob"}},
+ },
+ {
+ "There are 0 of a max of 20 players online:",
+ Status{Online: true, Count: 0, Max: 20, Players: []string{}},
+ },
+ {
+ "There are 1 of a max of 20 players online: Steve\n",
+ Status{Online: true, Count: 1, Max: 20, Players: []string{"Steve"}},
+ },
+ {
+ "some unexpected response",
+ Status{Online: true, Count: 0, Max: 0, Players: []string{}},
+ },
+ }
+
+ for _, c := range cases {
+ got := parseList(c.out)
+ if got.Players == nil {
+ got.Players = []string{}
+ }
+ if !reflect.DeepEqual(got, c.want) {
+ t.Errorf("parseList(%q) = %+v, want %+v", c.out, got, c.want)
+ }
+ }
+}
diff --git a/api/presence/presence.go b/api/presence/presence.go
new file mode 100644
index 0000000..645b34e
--- /dev/null
+++ b/api/presence/presence.go
@@ -0,0 +1,64 @@
+package presence
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+
+ "penguins.lan/api/types"
+ "penguins.lan/api/ws"
+ "penguins.lan/database"
+ "penguins.lan/utils"
+)
+
+type Waddler struct {
+ Username string `json:"username"`
+ Status string `json:"status"`
+ LastSeen string `json:"lastSeen"`
+}
+
+func waddlers(context *types.RequestContext, hub *ws.Hub) []Waddler {
+ users, err := database.ListUsers(context.DBConn)
+ if err != nil {
+ log.Println("presence: list users failed:", err)
+ }
+ onChat := hub.Online()
+ onNetwork, err := database.NetworkUserIDs(context.DBConn)
+ if err != nil {
+ log.Println("presence: network ids failed:", err)
+ }
+
+ list := make([]Waddler, 0, len(users))
+ for _, u := range users {
+ status := "offline"
+ if onNetwork[u.ID] {
+ status = "network"
+ }
+ if onChat[u.ID] {
+ status = "chat"
+ }
+ list = append(list, Waddler{u.Username, status, utils.FormatSince(u.LastSeen)})
+ }
+ return list
+}
+
+func RosterContinuation(hub *ws.Hub) types.Continuation {
+ return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ (*context.TemplateData)["Users"] = waddlers(context, hub)
+ return success(context, req, resp)
+ }
+ }
+}
+
+func StatusContinuation(hub *ws.Hub) types.Continuation {
+ return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ resp.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(resp).Encode(waddlers(context, hub)); err != nil {
+ log.Println("presence: encode failed:", err)
+ }
+ return success(context, req, resp)
+ }
+ }
+}
diff --git a/api/serve.go b/api/serve.go
new file mode 100644
index 0000000..0005369
--- /dev/null
+++ b/api/serve.go
@@ -0,0 +1,165 @@
+package api
+
+import (
+ "database/sql"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "penguins.lan/api/auth"
+ "penguins.lan/api/canvas"
+ "penguins.lan/api/minecraft"
+ "penguins.lan/api/presence"
+ "penguins.lan/api/template"
+ "penguins.lan/api/types"
+ "penguins.lan/api/ws"
+ "penguins.lan/args"
+ "penguins.lan/utils"
+)
+
+func LogRequestContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ context.Start = time.Now()
+ context.Id = utils.RandomId()
+
+ log.Println(req.Method, req.URL.Path, req.RemoteAddr, context.Id)
+ return success(context, req, resp)
+ }
+}
+
+func LogExecutionTimeContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ log.Println(context.Id, "took", time.Since(context.Start))
+ return success(context, req, resp)
+ }
+}
+
+func HealthCheckContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ resp.WriteHeader(http.StatusOK)
+ resp.Write([]byte("healthy"))
+ return success(context, req, resp)
+ }
+}
+
+func FailurePassingContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(_success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ return failure(context, req, resp)
+ }
+}
+
+func IdContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ return success(context, req, resp)
+ }
+}
+
+func CacheControlMiddleware(next http.Handler, maxAge int) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
+ next.ServeHTTP(w, r)
+ })
+}
+
+func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server {
+ mux := http.NewServeMux()
+
+ hub := ws.NewHub(dbConn)
+ go hub.Run()
+
+ canvasHub := canvas.NewHub(dbConn)
+ go canvasHub.Run()
+
+ makeRequestContext := func() *types.RequestContext {
+ return &types.RequestContext{
+ DBConn: dbConn,
+ Args: argv,
+ TemplateData: &map[string]interface{}{},
+ }
+ }
+
+ staticFileServer := http.FileServer(http.Dir(argv.StaticPath))
+ mux.Handle("GET /static/", http.StripPrefix("/static/", CacheControlMiddleware(staticFileServer, 3600)))
+
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(HealthCheckContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /portal", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.ShowPortalContinuation, FailurePassingContinuation)(template.TemplateContinuation("portal.html", true), IdContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("POST /portal", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.SetupUserContinuation, FailurePassingContinuation)(IdContinuation, template.TemplateContinuation("portal.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /profile", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(template.TemplateContinuation("profile.html", true), IdContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /logout", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.LogoutContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(presence.RosterContinuation(hub), FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(template.TemplateContinuation("home.html", true), auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("POST /rename", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(presence.RosterContinuation(hub), auth.GoPortalContinuation)(auth.RenameContinuation, auth.GoPortalContinuation)(IdContinuation, template.TemplateContinuation("home.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /waddlers", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(presence.StatusContinuation(hub), auth.UnauthorizedContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /canvas", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(canvas.PageContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(template.TemplateContinuation("canvas.html", true), auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /canvas/state", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(canvas.StateContinuation, auth.UnauthorizedContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /canvas/ws", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(canvas.ServeWSContinuation(canvasHub), auth.UnauthorizedContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /minecraft", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(minecraft.StatusContinuation, auth.UnauthorizedContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /chat", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(template.TemplateContinuation("chat.html", true), auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /ws", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(ws.ServeWSContinuation(hub), auth.UnauthorizedContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /{template}", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ templateFile := r.PathValue("template")
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(template.TemplateContinuation(templateFile+".html", true), auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ return &http.Server{
+ Addr: ":" + fmt.Sprint(argv.Port),
+ Handler: mux,
+ }
+}
diff --git a/api/template/template.go b/api/template/template.go
new file mode 100644
index 0000000..f1f4acd
--- /dev/null
+++ b/api/template/template.go
@@ -0,0 +1,77 @@
+package template
+
+import (
+ "bytes"
+ "errors"
+ "html/template"
+ "log"
+ "net/http"
+ "os"
+
+ "penguins.lan/api/types"
+)
+
+func renderTemplate(context *types.RequestContext, templateName string, showBaseHtml bool) (bytes.Buffer, error) {
+ templatePath := context.Args.TemplatePath
+ basePath := templatePath + "/base_empty.html"
+ if showBaseHtml {
+ basePath = templatePath + "/base.html"
+ }
+
+ templateLocation := templatePath + "/" + templateName
+ tmpl, err := template.New("").ParseFiles(templateLocation, basePath)
+ if err != nil {
+ return bytes.Buffer{}, err
+ }
+
+ dataPtr := context.TemplateData
+ if dataPtr == nil {
+ dataPtr = &map[string]interface{}{}
+ }
+
+ data := *dataPtr
+ if data["Nick"] == nil {
+ data["Nick"] = context.Nick
+ }
+ if data["User"] == nil {
+ data["User"] = context.User
+ }
+
+ var buffer bytes.Buffer
+ if err := tmpl.ExecuteTemplate(&buffer, "base", data); err != nil {
+ return bytes.Buffer{}, err
+ }
+ return buffer, nil
+}
+
+func TemplateContinuation(path string, showBase bool) types.Continuation {
+ return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ html, err := renderTemplate(context, path, showBase)
+ if errors.Is(err, os.ErrNotExist) {
+ resp.WriteHeader(http.StatusNotFound)
+ html, err = renderTemplate(context, "404.html", true)
+ if err != nil {
+ log.Println("error rendering 404 template", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return failure(context, req, resp)
+ }
+
+ resp.Header().Set("Content-Type", "text/html")
+ resp.Write(html.Bytes())
+ return failure(context, req, resp)
+ }
+
+ if err != nil {
+ log.Println("error rendering template", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ resp.Write([]byte("error rendering template"))
+ return failure(context, req, resp)
+ }
+
+ resp.Header().Set("Content-Type", "text/html")
+ resp.Write(html.Bytes())
+ return success(context, req, resp)
+ }
+ }
+}
diff --git a/api/types/types.go b/api/types/types.go
new file mode 100644
index 0000000..dc096d8
--- /dev/null
+++ b/api/types/types.go
@@ -0,0 +1,37 @@
+package types
+
+import (
+ "database/sql"
+ "net/http"
+ "time"
+
+ "penguins.lan/args"
+ "penguins.lan/database"
+)
+
+type RequestContext struct {
+ DBConn *sql.DB
+ Args *args.Arguments
+
+ Id string
+ Start time.Time
+
+ // User is the authenticated account (nil until resolved/required).
+ User *database.User
+ // ClientMAC is the visitor's hardware address, if we could read it.
+ ClientMAC string
+ // Nick mirrors User.Username for templates and the chat hub.
+ Nick string
+
+ TemplateData *map[string]interface{}
+}
+
+type BannerMessages struct {
+ Messages []string
+}
+
+// Continuation-passing style: each step is a Continuation that, given a
+// (success, failure) pair, returns the next link in the chain. This lets routes
+// read as a flat pipeline in serve.go while still branching on errors.
+type Continuation func(*RequestContext, *http.Request, http.ResponseWriter) ContinuationChain
+type ContinuationChain func(Continuation, Continuation) ContinuationChain
diff --git a/api/ws/ws.go b/api/ws/ws.go
new file mode 100644
index 0000000..63706db
--- /dev/null
+++ b/api/ws/ws.go
@@ -0,0 +1,293 @@
+package ws
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/gorilla/websocket"
+
+ "penguins.lan/api/types"
+ "penguins.lan/database"
+ "penguins.lan/utils"
+)
+
+const (
+ writeWait = 10 * time.Second
+ pongWait = 60 * time.Second
+ pingPeriod = (pongWait * 9) / 10
+ maxMessageSize = 4096
+
+ historyLimit = 50
+)
+
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ // It's a LAN intranet; we don't police origins.
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+// Message is the wire format shared by client and server. `Type` discriminates
+// the payload so we can grow beyond chat without breaking old clients.
+type Message struct {
+ Type string `json:"type"` // "chat" | "system" | "history"
+ Nick string `json:"nick,omitempty"`
+ Body string `json:"body,omitempty"`
+ At int64 `json:"at,omitempty"` // unix millis
+ // History carries a batch of past messages on join.
+ History []Message `json:"history,omitempty"`
+}
+
+// Hub fans messages out to every connected client.
+type Hub struct {
+ db *sql.DB
+ clients map[*Client]bool
+ broadcast chan []byte
+ register chan *Client
+ unregister chan *Client
+ online chan chan map[string]bool
+}
+
+func NewHub(db *sql.DB) *Hub {
+ return &Hub{
+ db: db,
+ clients: make(map[*Client]bool),
+ broadcast: make(chan []byte),
+ register: make(chan *Client),
+ unregister: make(chan *Client),
+ online: make(chan chan map[string]bool),
+ }
+}
+
+// Run owns all mutations to the client set, so no locks are needed elsewhere.
+func (h *Hub) Run() {
+ for {
+ select {
+ case client := <-h.register:
+ h.clients[client] = true
+ h.systemf("%s waddled in 🐧", h.displayName(client.userID))
+ case client := <-h.unregister:
+ if _, ok := h.clients[client]; ok {
+ delete(h.clients, client)
+ close(client.send)
+ h.systemf("%s slid away", h.displayName(client.userID))
+ }
+ case payload := <-h.broadcast:
+ h.deliver(payload)
+ case reply := <-h.online:
+ set := make(map[string]bool, len(h.clients))
+ for client := range h.clients {
+ set[client.userID] = true
+ }
+ reply <- set
+ }
+ }
+}
+
+// Online returns the set of userIDs currently connected to the chat.
+func (h *Hub) Online() map[string]bool {
+ reply := make(chan map[string]bool)
+ h.online <- reply
+ return <-reply
+}
+
+// displayName resolves a userID to its current username. The chat hub never
+// caches names, so a rename is reflected everywhere immediately.
+func (h *Hub) displayName(userID string) string {
+ user, err := database.GetUserByID(h.db, userID)
+ if err != nil {
+ log.Println("ws: could not resolve user", userID, err)
+ return "a mystery penguin"
+ }
+ return user.Username
+}
+
+// deliver fans a payload out to every client, dropping any that can't keep up.
+// Only safe to call from the Run goroutine, which owns the clients map.
+func (h *Hub) deliver(payload []byte) {
+ for client := range h.clients {
+ select {
+ case client.send <- payload:
+ default:
+ // slow consumer: drop them rather than block the hub
+ delete(h.clients, client)
+ close(client.send)
+ }
+ }
+}
+
+// sendJSON queues a message for broadcast from outside the hub goroutine
+// (e.g. a chat message arriving on a client's readPump).
+func (h *Hub) sendJSON(msg Message) {
+ payload, err := json.Marshal(msg)
+ if err != nil {
+ log.Println("ws: marshal error", err)
+ return
+ }
+ h.broadcast <- payload
+}
+
+// systemf emits a system notice. It's only ever called from within Run, so it
+// fans out directly — re-entering the broadcast channel here would deadlock the
+// hub (the loop can't both send and receive on it at once).
+func (h *Hub) systemf(format string, args ...interface{}) {
+ payload, err := json.Marshal(Message{
+ Type: "system",
+ Body: fmt.Sprintf(format, args...),
+ At: time.Now().UnixMilli(),
+ })
+ if err != nil {
+ log.Println("ws: marshal error", err)
+ return
+ }
+ h.deliver(payload)
+}
+
+// Client is one websocket connection. Reads happen on readPump, writes on
+// writePump, communicating through the buffered `send` channel. Identity is the
+// userID alone — the display name is always resolved from it, never cached.
+type Client struct {
+ hub *Hub
+ conn *websocket.Conn
+ send chan []byte
+ userID string
+}
+
+func (c *Client) readPump() {
+ defer func() {
+ c.hub.unregister <- c
+ c.conn.Close()
+ }()
+
+ c.conn.SetReadLimit(maxMessageSize)
+ c.conn.SetReadDeadline(time.Now().Add(pongWait))
+ c.conn.SetPongHandler(func(string) error {
+ c.conn.SetReadDeadline(time.Now().Add(pongWait))
+ return nil
+ })
+
+ for {
+ _, raw, err := c.conn.ReadMessage()
+ if err != nil {
+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
+ log.Println("ws: read error", err)
+ }
+ break
+ }
+
+ var incoming Message
+ if err := json.Unmarshal(raw, &incoming); err != nil {
+ continue
+ }
+
+ // The server is authoritative about who you are and when it happened.
+ body := utils.Sanitize(incoming.Body)
+ if incoming.Type != "chat" || body == "" {
+ continue
+ }
+
+ stored := &database.ChatMessage{
+ ID: utils.RandomId(),
+ UserID: c.userID,
+ Body: utils.Truncate(incoming.Body, 500),
+ }
+ if _, err := database.SaveChatMessage(c.hub.db, stored); err != nil {
+ log.Println("ws: failed to persist chat", err)
+ }
+
+ c.hub.sendJSON(Message{
+ Type: "chat",
+ Nick: c.hub.displayName(c.userID),
+ Body: stored.Body,
+ At: time.Now().UnixMilli(),
+ })
+ }
+}
+
+func (c *Client) writePump() {
+ ticker := time.NewTicker(pingPeriod)
+ defer func() {
+ ticker.Stop()
+ c.conn.Close()
+ }()
+
+ for {
+ select {
+ case payload, ok := <-c.send:
+ c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if !ok {
+ c.conn.WriteMessage(websocket.CloseMessage, []byte{})
+ return
+ }
+ if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
+ return
+ }
+ case <-ticker.C:
+ c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+ return
+ }
+ }
+ }
+}
+
+// sendHistory replays recent messages to a freshly-connected client only.
+func (c *Client) sendHistory() {
+ rows, err := database.GetRecentChat(c.hub.db, historyLimit)
+ if err != nil {
+ log.Println("ws: failed to load history", err)
+ return
+ }
+
+ history := make([]Message, 0, len(rows))
+ for _, row := range rows {
+ history = append(history, Message{
+ Type: "chat",
+ Nick: row.Nick,
+ Body: row.Body,
+ At: row.CreatedAt.UnixMilli(),
+ })
+ }
+
+ payload, err := json.Marshal(Message{Type: "history", History: history})
+ if err != nil {
+ log.Println("ws: marshal history error", err)
+ return
+ }
+ c.send <- payload
+}
+
+// ServeWSContinuation upgrades the request to a websocket and wires the client
+// into the hub. It's terminal in a route chain — after upgrade there's no HTTP
+// response left to render.
+func ServeWSContinuation(hub *Hub) types.Continuation {
+ return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ conn, err := upgrader.Upgrade(resp, req, nil)
+ if err != nil {
+ log.Println("ws: upgrade failed", err)
+ return failure(context, req, resp)
+ }
+
+ client := &Client{
+ hub: hub,
+ conn: conn,
+ send: make(chan []byte, 256),
+ userID: context.User.ID,
+ }
+
+ // Queue history first (send is buffered), then announce the join.
+ client.sendHistory()
+ client.hub.register <- client
+
+ go client.writePump()
+ go client.readPump()
+
+ return success(context, req, resp)
+ }
+ }
+}