diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-06-20 09:40:07 -0700 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-06-20 09:40:07 -0700 |
| commit | fb653292612eddca5b088ed88466c546d1597107 (patch) | |
| tree | 900ed32e066812688858ed3bb15128c41bb78054 /api | |
| download | penguins.lan-fb653292612eddca5b088ed88466c546d1597107.tar.gz penguins.lan-fb653292612eddca5b088ed88466c546d1597107.zip | |
Initial commit
Diffstat (limited to 'api')
| -rw-r--r-- | api/auth/auth.go | 280 | ||||
| -rw-r--r-- | api/canvas/canvas.go | 373 | ||||
| -rw-r--r-- | api/canvas/canvas_test.go | 83 | ||||
| -rw-r--r-- | api/minecraft/minecraft.go | 82 | ||||
| -rw-r--r-- | api/minecraft/minecraft_test.go | 40 | ||||
| -rw-r--r-- | api/presence/presence.go | 64 | ||||
| -rw-r--r-- | api/serve.go | 165 | ||||
| -rw-r--r-- | api/template/template.go | 77 | ||||
| -rw-r--r-- | api/types/types.go | 37 | ||||
| -rw-r--r-- | api/ws/ws.go | 293 |
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) + } + } +} |
