summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.dockerignore8
-rw-r--r--.env.example15
-rw-r--r--.gitignore6
-rw-r--r--README.md94
-rw-r--r--adapters/arp/arp.go63
-rw-r--r--adapters/arp/arp_test.go51
-rw-r--r--adapters/netscan/netscan.go74
-rw-r--r--adapters/netscan/netscan_test.go33
-rw-r--r--adapters/rcon/rcon.go130
-rw-r--r--adapters/rcon/rcon_test.go91
-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
-rw-r--r--args/args.go58
-rw-r--r--database/canvas.go80
-rw-r--r--database/chat.go55
-rw-r--r--database/conn.go17
-rw-r--r--database/connections.go105
-rw-r--r--database/connections_test.go63
-rw-r--r--database/migrate.go143
-rw-r--r--database/minecraft.go28
-rw-r--r--database/sessions.go44
-rw-r--r--database/users.go101
-rw-r--r--go.mod16
-rw-r--r--go.sum44
-rw-r--r--main.go51
-rw-r--r--scheduled/scheduled.go86
-rw-r--r--static/css/styles.css128
-rw-r--r--static/img/favicon.svg4
-rw-r--r--static/img/lab.pngbin0 -> 1724730 bytes
-rw-r--r--static/js/canvas.js97
-rw-r--r--static/js/chat.js101
-rw-r--r--static/js/minecraft.js30
-rw-r--r--static/js/waddlers.js30
-rw-r--r--templates/404.html4
-rw-r--r--templates/base.html46
-rw-r--r--templates/base_empty.html3
-rw-r--r--templates/canvas.html22
-rw-r--r--templates/home.html53
-rw-r--r--templates/portal.html66
-rw-r--r--templates/profile.html8
-rw-r--r--templates/waddlers.html3
-rw-r--r--utils/net.go59
-rw-r--r--utils/net_test.go63
-rw-r--r--utils/random_id.go15
-rw-r--r--utils/text.go49
53 files changed, 3731 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..4205a95
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+.env
+.git
+.claude
+penguins
+*.db
+Dockerfile
+docker-compose.yml
+README.md
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9764eed
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,15 @@
+# penguins.lan config. Copy to .env (it's gitignored).
+#
+# DEV_MAC forces every client's MAC to this value so you can exercise the
+# MAC-based login flow locally, where the browser is on loopback and never
+# appears in the ARP table. NEVER set this in production β€” it would make every
+# visitor share one identity.
+# DEV_MAC=aa:bb:cc:dd:ee:ff
+
+# Minecraft RCON for the server status panel. Leave RCON_ADDRESS empty to disable.
+# RCON_ADDRESS=127.0.0.1:25575
+# RCON_PASSWORD=changeme
+
+# LAN_SUBNET is swept (TCP probes) so idle devices populate the ARP table and
+# show as "online". Leave empty to disable. Max /20.
+# LAN_SUBNET=192.168.8.0/24
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0bba11b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.env
+.DS_Store
+penguins
+*.db
+/db/*.db
+.claude
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..70de28d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,94 @@
+# 🐧 penguins.lan
+
+A little intranet for a battery-powered LAN with no internet. The successor to
+`inetravel` β€” same "have fun on the network" spirit, but a real Go app instead
+of PHP includes, and actually fun stuff: live chat now, an r/place-style shared
+canvas next.
+
+## Stack
+
+- **Go** standard-library HTTP server, no framework.
+- **SQLite** (`mattn/go-sqlite3`) for persistence.
+- **gorilla/websocket** for the realtime hub.
+- `html/template` for server-rendered pages.
+
+## Architecture
+
+Requests flow through a small continuation-passing pipeline. Each route in
+[`api/serve.go`](api/serve.go) reads as a flat chain of `Continuation`s, where
+every link gets a `(success, failure)` pair and decides which to call. Shared
+state for a request lives on `types.RequestContext`.
+
+```
+LogRequest -> ResolveSession -> RequireUser -> <feature continuations> -> Template -> LogTime
+```
+
+Layout:
+
+| path | what |
+|------------------|-------------------------------------------------------------|
+| `main.go` | entrypoint: load env, open db, migrate, serve |
+| `args/` | flag parsing |
+| `database/` | sqlite conn, migrations, per-feature queries |
+| `adapters/arp/` | resolves a client IP -> MAC from the host's ARP table |
+| `api/` | server wiring (`serve.go`) + feature packages |
+| `api/auth/` | **MAC-based identity** β€” sessions, portal, reclaim |
+| `api/ws/` | **websocket hub** β€” broadcast chat, foundation for realtime |
+| `api/template/` | html/template rendering + 404 handling |
+| `templates/` | server-rendered pages, wrapped in `base.html` |
+| `static/` | css / js / images |
+
+## Identity (`api/auth` + `adapters/arp`)
+
+No passwords β€” on a trust-based LAN your **MAC address is your credential**. The
+router (nodogsplash) keeps every client on one L2 segment, so the server reads
+each visitor's MAC from the ARP table (`arp -an`).
+
+- **First visit** β†’ the portal asks for a handle; claiming it creates a `user`,
+ binds your current MAC, and sets a session cookie.
+- **Return visit** β†’ a live session cookie logs you in; if it's gone, a
+ recognised MAC logs you straight back in.
+- **Rotated MAC** (privacy randomization) β†’ you show up as a new device. The
+ portal reports when that handle was *last online* and lets you **log back in**
+ to reclaim it, re-binding your new MAC to the existing account.
+
+One user can own many MACs (`macs` table). Chat messages reference `user_id`
+(not a name), so identity survives MAC rotation and renames.
+
+> **Local dev:** the browser is on loopback and never appears in the ARP table,
+> so set `DEV_MAC=aa:bb:cc:dd:ee:ff` to force a MAC and exercise the whole flow.
+> Never set it in production β€” it would make every visitor one shared identity.
+
+## Realtime (`api/ws`)
+
+The `Hub` owns the set of connected clients and a broadcast channel; all client
+set mutations happen in its single `Run()` goroutine, so no locks. Each `Client`
+has a `readPump` and `writePump`. Messages are JSON tagged by `type`
+(`chat` / `system` / `history`). To add a new realtime feature (e.g. the canvas),
+add a `type` and handle it in the hub + client β€” no new connection plumbing.
+
+## Run it
+
+```sh
+go mod download
+go run . --server --migrate
+# -> http://localhost:8080
+```
+
+Flags: `--server`, `--migrate`, `--port 8080`,
+`--database-path ./db/penguins.db`, `--template-path ./templates`,
+`--static-path ./static`.
+
+### Docker
+
+```sh
+docker compose up --build
+```
+
+## Roadmap
+
+- [x] base framework + live chat over websockets
+- [x] MAC-based accounts + portal with reclaim
+- [ ] r/place-style shared canvas
+- [ ] presence / who's online
+- [ ] more LAN games
diff --git a/adapters/arp/arp.go b/adapters/arp/arp.go
new file mode 100644
index 0000000..19d7632
--- /dev/null
+++ b/adapters/arp/arp.go
@@ -0,0 +1,63 @@
+package arp
+
+import (
+ "os/exec"
+ "regexp"
+ "strings"
+
+ "penguins.lan/utils"
+)
+
+type ArpTable struct {
+ AddrToMac map[utils.INet4]utils.Mac
+}
+
+// Lookup returns the canonical MAC for an IPv4 string, false if it isn't in the
+// table or isn't parseable IPv4 (e.g. an IPv6 peer).
+func (t *ArpTable) Lookup(ip string) (string, bool) {
+ addr, err := utils.ParseINet4(ip)
+ if err != nil {
+ return "", false
+ }
+ mac, ok := t.AddrToMac[*addr]
+ if !ok {
+ return "", false
+ }
+ return mac.Format(), true
+}
+
+type TableProvider interface {
+ Fetch() (*ArpTable, error)
+}
+
+var arpLine = regexp.MustCompile(`\((\d{1,3}(?:\.\d{1,3}){3})\) at ([0-9a-fA-F]{1,2}(?::[0-9a-fA-F]{1,2}){5})`)
+
+type SystemProvider struct{}
+
+func (SystemProvider) Fetch() (*ArpTable, error) {
+ out, err := exec.Command("arp", "-an").Output()
+ if err != nil {
+ return nil, err
+ }
+ return parseTable(string(out)), nil
+}
+
+func parseTable(out string) *ArpTable {
+ table := &ArpTable{AddrToMac: make(map[utils.INet4]utils.Mac)}
+ for _, line := range strings.Split(out, "\n") {
+ m := arpLine.FindStringSubmatch(line)
+ if m == nil {
+ continue
+ }
+ addr, err := utils.ParseINet4(m[1])
+ if err != nil {
+ continue
+ }
+ mac, err := utils.ParseMac(m[2])
+ if err != nil {
+ continue
+ }
+ table.AddrToMac[*addr] = *mac
+ }
+ return table
+}
diff --git a/adapters/arp/arp_test.go b/adapters/arp/arp_test.go
new file mode 100644
index 0000000..9a215e0
--- /dev/null
+++ b/adapters/arp/arp_test.go
@@ -0,0 +1,51 @@
+package arp
+
+import "testing"
+
+const sample = `Neighbor Linklayer Address
+? (192.168.1.5) at 8:0:27:ab:cd:ef on en0 ifscope [ethernet]
+? (192.168.1.9) at AA:BB:CC:11:22:33 [ether] on br-lan
+? (10.0.0.2) at de:ad:be:ef:00:01 on eth0
+? (192.168.1.250) at (incomplete) on en0 [ethernet]
+
+`
+
+func TestParseTable(t *testing.T) {
+ table := parseTable(sample)
+
+ got := make(map[string]string, len(table.AddrToMac))
+ for ip, mac := range table.AddrToMac {
+ got[ip.Format()] = mac.Format()
+ }
+
+ want := map[string]string{
+ "192.168.1.5": "08:00:27:ab:cd:ef",
+ "192.168.1.9": "aa:bb:cc:11:22:33",
+ "10.0.0.2": "de:ad:be:ef:00:01",
+ }
+
+ if len(got) != len(want) {
+ t.Fatalf("parsed %d entries %v, want %d (header/incomplete lines should be skipped)", len(got), got, len(want))
+ }
+ for ip, mac := range want {
+ if got[ip] != mac {
+ t.Errorf("entry %q = %q, want %q", ip, got[ip], mac)
+ }
+ }
+}
+
+func TestTableLookup(t *testing.T) {
+ table := parseTable(sample)
+
+ if mac, ok := table.Lookup("192.168.1.5"); !ok || mac != "08:00:27:ab:cd:ef" {
+ t.Errorf("Lookup(192.168.1.5) = %q,%v", mac, ok)
+ }
+ if _, ok := table.Lookup("1.2.3.4"); ok {
+ t.Error("Lookup of a miss should be false")
+ }
+ if _, ok := table.Lookup("::1"); ok {
+ t.Error("Lookup of non-IPv4 should be false")
+ }
+}
+
+var _ TableProvider = SystemProvider{}
diff --git a/adapters/netscan/netscan.go b/adapters/netscan/netscan.go
new file mode 100644
index 0000000..5aaf415
--- /dev/null
+++ b/adapters/netscan/netscan.go
@@ -0,0 +1,74 @@
+// Package netscan nudges the kernel into ARP-resolving every host on a subnet,
+// so devices that never talk to us still show up in the neighbour table. It does
+// this by attempting a TCP connection to each host: the result is irrelevant,
+// the ARP exchange the kernel performs to send the SYN is the point.
+package netscan
+
+import (
+ "fmt"
+ "net"
+ "sync"
+ "time"
+)
+
+const (
+ probePort = "9" // discard; closed almost everywhere, so a fast RST
+ maxHostBits = 12 // refuse to sweep anything larger than a /20 (~4094 hosts)
+ sweepWorkers = 32
+)
+
+func Sweep(cidr string, timeout time.Duration) error {
+ hosts, err := hostIPs(cidr)
+ if err != nil {
+ return err
+ }
+
+ sem := make(chan struct{}, sweepWorkers)
+ var wg sync.WaitGroup
+ for _, ip := range hosts {
+ wg.Add(1)
+ sem <- struct{}{}
+ go func(ip string) {
+ defer wg.Done()
+ defer func() { <-sem }()
+ if conn, err := net.DialTimeout("tcp", net.JoinHostPort(ip, probePort), timeout); err == nil {
+ conn.Close()
+ }
+ }(ip)
+ }
+ wg.Wait()
+ return nil
+}
+
+func hostIPs(cidr string) ([]string, error) {
+ _, ipnet, err := net.ParseCIDR(cidr)
+ if err != nil {
+ return nil, err
+ }
+ if ones, bits := ipnet.Mask.Size(); bits-ones > maxHostBits {
+ return nil, fmt.Errorf("netscan: subnet /%d too large to sweep", ones)
+ }
+
+ ip := make(net.IP, len(ipnet.IP))
+ copy(ip, ipnet.IP)
+
+ ips := []string{}
+ for ; ipnet.Contains(ip); inc(ip) {
+ ips = append(ips, ip.String())
+ }
+
+ // drop the network and broadcast addresses
+ if len(ips) > 2 {
+ ips = ips[1 : len(ips)-1]
+ }
+ return ips, nil
+}
+
+func inc(ip net.IP) {
+ for i := len(ip) - 1; i >= 0; i-- {
+ ip[i]++
+ if ip[i] != 0 {
+ break
+ }
+ }
+}
diff --git a/adapters/netscan/netscan_test.go b/adapters/netscan/netscan_test.go
new file mode 100644
index 0000000..b50ef0c
--- /dev/null
+++ b/adapters/netscan/netscan_test.go
@@ -0,0 +1,33 @@
+package netscan
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestHostIPs(t *testing.T) {
+ ips, err := hostIPs("192.168.1.0/30")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"192.168.1.1", "192.168.1.2"}; !reflect.DeepEqual(ips, want) {
+ t.Errorf("/30 = %v, want %v", ips, want)
+ }
+
+ ips, err = hostIPs("10.0.0.0/24")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ips) != 254 || ips[0] != "10.0.0.1" || ips[253] != "10.0.0.254" {
+ t.Errorf("/24 = %d hosts (%q..%q), want 254 (.1...254)", len(ips), ips[0], ips[len(ips)-1])
+ }
+}
+
+func TestHostIPsRejects(t *testing.T) {
+ if _, err := hostIPs("10.0.0.0/8"); err == nil {
+ t.Error("oversized subnet should error")
+ }
+ if _, err := hostIPs("not-a-cidr"); err == nil {
+ t.Error("bad cidr should error")
+ }
+}
diff --git a/adapters/rcon/rcon.go b/adapters/rcon/rcon.go
new file mode 100644
index 0000000..4b9e8b6
--- /dev/null
+++ b/adapters/rcon/rcon.go
@@ -0,0 +1,130 @@
+// Package rcon is a minimal Source RCON client (the protocol Minecraft speaks),
+// enough to authenticate and run a command like "list".
+package rcon
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "io"
+ "net"
+ "time"
+)
+
+const (
+ typeResponse int32 = 0
+ typeAuthResp int32 = 2
+ typeExecCommand int32 = 2
+ typeAuth int32 = 3
+
+ authRequestID int32 = 0
+ execRequestID int32 = 1
+
+ headerSize = 8 // id (int32) + type (int32)
+ paddingSize = 2 // two trailing null bytes
+ maxBodySize = 4096
+
+ DefaultTimeout = 5 * time.Second
+)
+
+var (
+ ErrAuthFailed = errors.New("rcon: authentication failed")
+ ErrResponseMismatch = errors.New("rcon: response id mismatch")
+ ErrPacketSize = errors.New("rcon: invalid packet size")
+)
+
+type Client struct {
+ conn net.Conn
+ timeout time.Duration
+}
+
+func Dial(address, password string, timeout time.Duration) (*Client, error) {
+ conn, err := net.DialTimeout("tcp", address, timeout)
+ if err != nil {
+ return nil, err
+ }
+
+ client := &Client{conn: conn, timeout: timeout}
+ if err := client.auth(password); err != nil {
+ conn.Close()
+ return nil, err
+ }
+ return client, nil
+}
+
+func (c *Client) Close() error { return c.conn.Close() }
+
+func (c *Client) auth(password string) error {
+ if err := c.write(authRequestID, typeAuth, password); err != nil {
+ return err
+ }
+
+ // Some servers send an empty SERVERDATA_RESPONSE_VALUE before the auth
+ // response; skip anything that isn't the auth response.
+ for {
+ id, typ, _, err := c.read()
+ if err != nil {
+ return err
+ }
+ if typ != typeAuthResp {
+ continue
+ }
+ if id == -1 {
+ return ErrAuthFailed
+ }
+ return nil
+ }
+}
+
+func (c *Client) Execute(command string) (string, error) {
+ if err := c.write(execRequestID, typeExecCommand, command); err != nil {
+ return "", err
+ }
+
+ id, _, body, err := c.read()
+ if err != nil {
+ return "", err
+ }
+ if id != execRequestID {
+ return "", ErrResponseMismatch
+ }
+ return body, nil
+}
+
+func (c *Client) write(id, typ int32, body string) error {
+ c.conn.SetWriteDeadline(time.Now().Add(c.timeout))
+
+ var buf bytes.Buffer
+ size := int32(headerSize + len(body) + paddingSize)
+ binary.Write(&buf, binary.LittleEndian, size)
+ binary.Write(&buf, binary.LittleEndian, id)
+ binary.Write(&buf, binary.LittleEndian, typ)
+ buf.WriteString(body)
+ buf.Write([]byte{0, 0})
+
+ _, err := c.conn.Write(buf.Bytes())
+ return err
+}
+
+func (c *Client) read() (id, typ int32, body string, err error) {
+ c.conn.SetReadDeadline(time.Now().Add(c.timeout))
+
+ var size int32
+ if err = binary.Read(c.conn, binary.LittleEndian, &size); err != nil {
+ return
+ }
+ if size < headerSize+paddingSize || size > headerSize+maxBodySize+paddingSize {
+ err = ErrPacketSize
+ return
+ }
+
+ payload := make([]byte, size)
+ if _, err = io.ReadFull(c.conn, payload); err != nil {
+ return
+ }
+
+ id = int32(binary.LittleEndian.Uint32(payload[0:4]))
+ typ = int32(binary.LittleEndian.Uint32(payload[4:8]))
+ body = string(payload[headerSize : size-paddingSize])
+ return
+}
diff --git a/adapters/rcon/rcon_test.go b/adapters/rcon/rcon_test.go
new file mode 100644
index 0000000..f712fa5
--- /dev/null
+++ b/adapters/rcon/rcon_test.go
@@ -0,0 +1,91 @@
+package rcon
+
+import (
+ "encoding/binary"
+ "errors"
+ "io"
+ "net"
+ "testing"
+ "time"
+)
+
+func writeTestPacket(w io.Writer, id, typ int32, body string) {
+ binary.Write(w, binary.LittleEndian, int32(headerSize+len(body)+paddingSize))
+ binary.Write(w, binary.LittleEndian, id)
+ binary.Write(w, binary.LittleEndian, typ)
+ io.WriteString(w, body)
+ w.Write([]byte{0, 0})
+}
+
+func readTestPacket(r io.Reader) (id, typ int32, body string) {
+ var size int32
+ binary.Read(r, binary.LittleEndian, &size)
+ payload := make([]byte, size)
+ io.ReadFull(r, payload)
+ id = int32(binary.LittleEndian.Uint32(payload[0:4]))
+ typ = int32(binary.LittleEndian.Uint32(payload[4:8]))
+ return id, typ, string(payload[headerSize : size-paddingSize])
+}
+
+// fakeServer speaks just enough Source RCON to exercise the client.
+func fakeServer(t *testing.T, password string) string {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ln.Close() })
+
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+
+ id, typ, body := readTestPacket(conn)
+ if typ != typeAuth {
+ return
+ }
+ respID := id
+ if body != password {
+ respID = -1
+ }
+ writeTestPacket(conn, id, typeResponse, "") // empty value first
+ writeTestPacket(conn, respID, typeAuthResp, "")
+ if respID == -1 {
+ return
+ }
+
+ cid, _, cbody := readTestPacket(conn)
+ out := "unknown command"
+ if cbody == "list" {
+ out = "There are 1 of a max of 20 players online: Steve"
+ }
+ writeTestPacket(conn, cid, typeResponse, out)
+ }()
+
+ return ln.Addr().String()
+}
+
+func TestClientExecute(t *testing.T) {
+ client, err := Dial(fakeServer(t, "secret"), "secret", time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+
+ out, err := client.Execute("list")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := "There are 1 of a max of 20 players online: Steve"; out != want {
+ t.Errorf("Execute = %q, want %q", out, want)
+ }
+}
+
+func TestClientAuthFailure(t *testing.T) {
+ _, err := Dial(fakeServer(t, "secret"), "wrong", time.Second)
+ if !errors.Is(err, ErrAuthFailed) {
+ t.Errorf("Dial err = %v, want ErrAuthFailed", err)
+ }
+}
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)
+ }
+ }
+}
diff --git a/args/args.go b/args/args.go
new file mode 100644
index 0000000..6655f4a
--- /dev/null
+++ b/args/args.go
@@ -0,0 +1,58 @@
+package args
+
+import (
+ "flag"
+ "os"
+)
+
+type Arguments struct {
+ DatabasePath string
+ TemplatePath string
+ StaticPath string
+
+ Migrate bool
+ Scheduler bool
+
+ Port int
+ Server bool
+
+ // DevMAC (env DEV_MAC) forces every client's MAC, for testing auth from
+ // loopback where the browser never appears in the ARP table.
+ DevMAC string
+
+ // Minecraft RCON, for the server status panel. Empty address disables it.
+ RconAddress string
+ RconPassword string
+
+ // LanSubnet (env LAN_SUBNET, e.g. 192.168.8.0/24) is swept to populate the
+ // ARP table for idle devices. Empty disables the sweep.
+ LanSubnet string
+}
+
+func GetArgs() (*Arguments, error) {
+ databasePath := flag.String("database-path", "./db/penguins.db", "Path to the SQLite database")
+ templatePath := flag.String("template-path", "./templates", "Path to the template directory")
+ staticPath := flag.String("static-path", "./static", "Path to the static directory")
+
+ migrate := flag.Bool("migrate", false, "Run the migrations on startup")
+ scheduler := flag.Bool("scheduler", false, "Run the background scheduler (ARP refresh, session cleanup)")
+
+ port := flag.Int("port", 8080, "Port to listen on")
+ server := flag.Bool("server", false, "Run the HTTP server")
+
+ flag.Parse()
+
+ return &Arguments{
+ DatabasePath: *databasePath,
+ TemplatePath: *templatePath,
+ StaticPath: *staticPath,
+ Migrate: *migrate,
+ Scheduler: *scheduler,
+ Port: *port,
+ Server: *server,
+ DevMAC: os.Getenv("DEV_MAC"),
+ RconAddress: os.Getenv("RCON_ADDRESS"),
+ RconPassword: os.Getenv("RCON_PASSWORD"),
+ LanSubnet: os.Getenv("LAN_SUBNET"),
+ }, nil
+}
diff --git a/database/canvas.go b/database/canvas.go
new file mode 100644
index 0000000..57823d4
--- /dev/null
+++ b/database/canvas.go
@@ -0,0 +1,80 @@
+package database
+
+import (
+ "database/sql"
+ "errors"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+type Pixel struct {
+ ID int64
+ X int
+ Y int
+ Color int
+}
+
+// CanvasSnapshot is a materialized canvas (RGB bytes) plus the id of the last
+// pixel baked into it, so replaying just needs pixels with a greater id.
+type CanvasSnapshot struct {
+ Data []byte
+ ThroughID int64
+}
+
+func SavePixel(db *sql.DB, userID string, x, y, color int) (int64, error) {
+ res, err := db.Exec(
+ "INSERT INTO canvas_pixels (user_id, x, y, color, created_at) VALUES (?, ?, ?, ?, ?)",
+ userID, x, y, color, time.Now(),
+ )
+ if err != nil {
+ return 0, err
+ }
+ return res.LastInsertId()
+}
+
+func LatestCanvasSnapshot(db *sql.DB) (*CanvasSnapshot, error) {
+ var s CanvasSnapshot
+ err := db.QueryRow("SELECT data, through_id FROM canvas_snapshots ORDER BY id DESC LIMIT 1").Scan(&s.Data, &s.ThroughID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &s, nil
+}
+
+func PixelsAfter(db *sql.DB, throughID int64) ([]Pixel, error) {
+ rows, err := db.Query("SELECT id, x, y, color FROM canvas_pixels WHERE id > ? ORDER BY id", throughID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ pixels := []Pixel{}
+ for rows.Next() {
+ var p Pixel
+ if err := rows.Scan(&p.ID, &p.X, &p.Y, &p.Color); err != nil {
+ return nil, err
+ }
+ pixels = append(pixels, p)
+ }
+ return pixels, rows.Err()
+}
+
+func CountPixelsAfter(db *sql.DB, throughID int64) (int, error) {
+ var n int
+ err := db.QueryRow("SELECT COUNT(*) FROM canvas_pixels WHERE id > ?", throughID).Scan(&n)
+ return n, err
+}
+
+func SaveCanvasSnapshot(db *sql.DB, data []byte, throughID int64) error {
+ _, err := db.Exec("INSERT INTO canvas_snapshots (data, through_id, created_at) VALUES (?, ?, ?)", data, throughID, time.Now())
+ return err
+}
+
+func DeleteOldCanvasSnapshots(db *sql.DB) error {
+ _, err := db.Exec("DELETE FROM canvas_snapshots WHERE id NOT IN (SELECT id FROM canvas_snapshots ORDER BY id DESC LIMIT 1)")
+ return err
+}
diff --git a/database/chat.go b/database/chat.go
new file mode 100644
index 0000000..66550cd
--- /dev/null
+++ b/database/chat.go
@@ -0,0 +1,55 @@
+package database
+
+import (
+ "database/sql"
+ "log"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+type ChatMessage struct {
+ ID string `json:"id"`
+ UserID string `json:"user_id"`
+ Nick string `json:"nick"`
+ Body string `json:"body"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+func GetRecentChat(db *sql.DB, limit int) ([]ChatMessage, error) {
+ rows, err := db.Query(
+ `SELECT c.id, c.user_id, u.username, c.body, c.created_at
+ FROM chat c JOIN users u ON u.id = c.user_id
+ ORDER BY c.created_at DESC LIMIT ?`,
+ limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ messages := []ChatMessage{}
+ for rows.Next() {
+ var msg ChatMessage
+ if err := rows.Scan(&msg.ID, &msg.UserID, &msg.Nick, &msg.Body, &msg.CreatedAt); err != nil {
+ return nil, err
+ }
+ messages = append([]ChatMessage{msg}, messages...)
+ }
+
+ return messages, rows.Err()
+}
+
+func SaveChatMessage(db *sql.DB, msg *ChatMessage) (*ChatMessage, error) {
+ log.Println("saving chat message", msg.ID)
+
+ msg.CreatedAt = time.Now()
+ _, err := db.Exec(
+ "INSERT INTO chat (id, user_id, body, created_at) VALUES (?, ?, ?, ?)",
+ msg.ID, msg.UserID, msg.Body, msg.CreatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return msg, nil
+}
diff --git a/database/conn.go b/database/conn.go
new file mode 100644
index 0000000..4c41a9b
--- /dev/null
+++ b/database/conn.go
@@ -0,0 +1,17 @@
+package database
+
+import (
+ "database/sql"
+ "log"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+func MakeConn(databasePath *string) *sql.DB {
+ log.Println("opening database at", *databasePath, "with foreign keys enabled")
+ dbConn, err := sql.Open("sqlite3", *databasePath+"?_foreign_keys=on")
+ if err != nil {
+ panic(err)
+ }
+ return dbConn
+}
diff --git a/database/connections.go b/database/connections.go
new file mode 100644
index 0000000..e16a17e
--- /dev/null
+++ b/database/connections.go
@@ -0,0 +1,105 @@
+package database
+
+import (
+ "database/sql"
+ "errors"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+// connectionFreshness is how long a device counts as present after we last saw
+// it in the ARP table. It smooths over the kernel briefly aging an entry out.
+const connectionFreshness = 60 * time.Second
+
+type Connection struct {
+ IP string
+ Mac string
+ UpdatedAt time.Time
+}
+
+// RefreshConnections upserts the current ARP snapshot, bumping updated_at for
+// devices still present, and prunes ones not seen within the freshness window.
+func RefreshConnections(db *sql.DB, ipToMac map[string]string) error {
+ tx, err := db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ stmt, err := tx.Prepare(`INSERT INTO connections (ip, mac, updated_at) VALUES (?, ?, ?)
+ ON CONFLICT(ip) DO UPDATE SET mac = excluded.mac, updated_at = excluded.updated_at`)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ now := time.Now()
+ for ip, mac := range ipToMac {
+ if _, err := stmt.Exec(ip, mac, now); err != nil {
+ return err
+ }
+ }
+
+ if _, err := tx.Exec("DELETE FROM connections WHERE updated_at < ?", now.Add(-connectionFreshness)); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+func MacForIP(db *sql.DB, ip string) (string, error) {
+ var mac string
+ err := db.QueryRow(
+ "SELECT mac FROM connections WHERE ip = ? AND updated_at > ?",
+ ip, time.Now().Add(-connectionFreshness),
+ ).Scan(&mac)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", nil
+ }
+ return mac, err
+}
+
+// NetworkUserIDs returns the set of users whose MAC was seen on the LAN within
+// the freshness window, whether or not they're on the chat.
+func NetworkUserIDs(db *sql.DB) (map[string]bool, error) {
+ rows, err := db.Query(
+ "SELECT DISTINCT m.user_id FROM macs m JOIN connections c ON c.mac = m.mac WHERE c.updated_at > ?",
+ time.Now().Add(-connectionFreshness),
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ ids := map[string]bool{}
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ ids[id] = true
+ }
+ return ids, rows.Err()
+}
+
+func ListAliveConnections(db *sql.DB) ([]Connection, error) {
+ rows, err := db.Query(
+ "SELECT ip, mac, updated_at FROM connections WHERE updated_at > ? ORDER BY ip",
+ time.Now().Add(-connectionFreshness),
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ conns := []Connection{}
+ for rows.Next() {
+ var c Connection
+ if err := rows.Scan(&c.IP, &c.Mac, &c.UpdatedAt); err != nil {
+ return nil, err
+ }
+ conns = append(conns, c)
+ }
+ return conns, rows.Err()
+}
diff --git a/database/connections_test.go b/database/connections_test.go
new file mode 100644
index 0000000..b4ea57b
--- /dev/null
+++ b/database/connections_test.go
@@ -0,0 +1,63 @@
+package database
+
+import (
+ "database/sql"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func testDB(t *testing.T) *sql.DB {
+ db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { db.Close() })
+ if _, err := MigrateConnections(db); err != nil {
+ t.Fatal(err)
+ }
+ return db
+}
+
+func TestMacForIPFreshness(t *testing.T) {
+ db := testDB(t)
+
+ if err := RefreshConnections(db, map[string]string{"192.168.1.5": "aa:bb:cc:dd:ee:ff"}); err != nil {
+ t.Fatal(err)
+ }
+ if mac, _ := MacForIP(db, "192.168.1.5"); mac != "aa:bb:cc:dd:ee:ff" {
+ t.Fatalf("fresh entry = %q, want the mac", mac)
+ }
+
+ // age the entry past the window: it should stop resolving
+ db.Exec("UPDATE connections SET updated_at = ? WHERE ip = ?", time.Now().Add(-2*connectionFreshness), "192.168.1.5")
+ if mac, _ := MacForIP(db, "192.168.1.5"); mac != "" {
+ t.Errorf("stale entry resolved to %q, want empty", mac)
+ }
+
+ // seeing it again re-activates it
+ if err := RefreshConnections(db, map[string]string{"192.168.1.5": "aa:bb:cc:dd:ee:ff"}); err != nil {
+ t.Fatal(err)
+ }
+ if mac, _ := MacForIP(db, "192.168.1.5"); mac != "aa:bb:cc:dd:ee:ff" {
+ t.Errorf("re-seen entry = %q, want the mac", mac)
+ }
+}
+
+func TestRefreshConnectionsPrunesStale(t *testing.T) {
+ db := testDB(t)
+
+ RefreshConnections(db, map[string]string{"10.0.0.1": "aa:aa:aa:aa:aa:aa"})
+ db.Exec("UPDATE connections SET updated_at = ? WHERE ip = ?", time.Now().Add(-2*connectionFreshness), "10.0.0.1")
+
+ // a refresh with a different device prunes the aged-out one
+ RefreshConnections(db, map[string]string{"10.0.0.2": "bb:bb:bb:bb:bb:bb"})
+
+ conns, err := ListAliveConnections(db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(conns) != 1 || conns[0].IP != "10.0.0.2" {
+ t.Errorf("after prune = %+v, want only 10.0.0.2", conns)
+ }
+}
diff --git a/database/migrate.go b/database/migrate.go
new file mode 100644
index 0000000..24d47b6
--- /dev/null
+++ b/database/migrate.go
@@ -0,0 +1,143 @@
+package database
+
+import (
+ "database/sql"
+ "log"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+type Migrator func(*sql.DB) (*sql.DB, error)
+
+func MigrateUsers(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating users table")
+
+ _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ username TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );`)
+ if err != nil {
+ return dbConn, err
+ }
+
+ _, err = dbConn.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username);`)
+ return dbConn, err
+}
+
+// MigrateMacs maps hardware addresses to users. One user can own many MACs β€”
+// that's how a privacy-rotated device "logs back in" to an existing account.
+func MigrateMacs(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating macs table")
+
+ _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS macs (
+ mac TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ );`)
+ return dbConn, err
+}
+
+func MigrateSessions(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating sessions table")
+
+ _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ expire_at TIMESTAMP NOT NULL,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ );`)
+ return dbConn, err
+}
+
+func MigrateChat(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating chat table")
+
+ _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS chat (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ body TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ );`)
+ if err != nil {
+ return dbConn, err
+ }
+
+ _, err = dbConn.Exec(`CREATE INDEX IF NOT EXISTS idx_chat_created_at ON chat (created_at);`)
+ return dbConn, err
+}
+
+func MigrateConnections(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating connections table")
+
+ _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS connections (
+ ip TEXT PRIMARY KEY,
+ mac TEXT NOT NULL,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );`)
+ return dbConn, err
+}
+
+func MigrateMinecraft(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating minecraft_status table")
+
+ _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS minecraft_status (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ status TEXT NOT NULL,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );`)
+ return dbConn, err
+}
+
+func MigrateCanvas(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating canvas tables")
+
+ _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS canvas_pixels (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT,
+ x INTEGER NOT NULL,
+ y INTEGER NOT NULL,
+ color INTEGER NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
+ );`)
+ if err != nil {
+ return dbConn, err
+ }
+
+ _, err = dbConn.Exec(`CREATE TABLE IF NOT EXISTS canvas_snapshots (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ data BLOB NOT NULL,
+ through_id INTEGER NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );`)
+ return dbConn, err
+}
+
+func Migrate(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating database")
+
+ migrations := []Migrator{
+ MigrateUsers,
+ MigrateMacs,
+ MigrateSessions,
+ MigrateChat,
+ MigrateConnections,
+ MigrateMinecraft,
+ MigrateCanvas,
+ }
+
+ for _, migration := range migrations {
+ var err error
+ if dbConn, err = migration(dbConn); err != nil {
+ return dbConn, err
+ }
+ }
+
+ return dbConn, nil
+}
diff --git a/database/minecraft.go b/database/minecraft.go
new file mode 100644
index 0000000..d12b4b8
--- /dev/null
+++ b/database/minecraft.go
@@ -0,0 +1,28 @@
+package database
+
+import (
+ "database/sql"
+ "errors"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+func SaveMinecraftStatus(db *sql.DB, status string) error {
+ _, err := db.Exec(`INSERT INTO minecraft_status (id, status, updated_at) VALUES (1, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at`,
+ status, time.Now())
+ return err
+}
+
+// GetMinecraftStatus returns the cached status JSON and when it was written. An
+// empty string (with no error) means nothing has been cached yet.
+func GetMinecraftStatus(db *sql.DB) (string, time.Time, error) {
+ var status string
+ var updatedAt time.Time
+ err := db.QueryRow("SELECT status, updated_at FROM minecraft_status WHERE id = 1").Scan(&status, &updatedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", time.Time{}, nil
+ }
+ return status, updatedAt, err
+}
diff --git a/database/sessions.go b/database/sessions.go
new file mode 100644
index 0000000..5644281
--- /dev/null
+++ b/database/sessions.go
@@ -0,0 +1,44 @@
+package database
+
+import (
+ "database/sql"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+type Session struct {
+ ID string
+ UserID string
+ ExpireAt time.Time
+}
+
+func CreateSession(db *sql.DB, id, userID string, ttl time.Duration) (*Session, error) {
+ now := time.Now()
+ expire := now.Add(ttl)
+ _, err := db.Exec(
+ "INSERT INTO sessions (id, user_id, created_at, expire_at) VALUES (?, ?, ?, ?)",
+ id, userID, now, expire,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return &Session{ID: id, UserID: userID, ExpireAt: expire}, nil
+}
+
+func GetSessionUser(db *sql.DB, id string) (*User, error) {
+ return scanUser(db.QueryRow(
+ "SELECT u.id, u.username, u.created_at, u.last_seen FROM users u JOIN sessions s ON s.user_id = u.id WHERE s.id = ? AND s.expire_at > ?",
+ id, time.Now(),
+ ))
+}
+
+func DeleteExpiredSessions(db *sql.DB) error {
+ _, err := db.Exec("DELETE FROM sessions WHERE expire_at < ?", time.Now())
+ return err
+}
+
+func DeleteSession(db *sql.DB, id string) error {
+ _, err := db.Exec("DELETE FROM sessions WHERE id = ?", id)
+ return err
+}
diff --git a/database/users.go b/database/users.go
new file mode 100644
index 0000000..169cce1
--- /dev/null
+++ b/database/users.go
@@ -0,0 +1,101 @@
+package database
+
+import (
+ "database/sql"
+ "errors"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+var ErrNotFound = errors.New("not found")
+
+type User struct {
+ ID string
+ Username string
+ CreatedAt time.Time
+ LastSeen time.Time
+}
+
+func scanUser(row *sql.Row) (*User, error) {
+ var u User
+ err := row.Scan(&u.ID, &u.Username, &u.CreatedAt, &u.LastSeen)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &u, nil
+}
+
+const userColumns = "id, username, created_at, last_seen"
+
+func CreateUser(db *sql.DB, id, username string) (*User, error) {
+ now := time.Now()
+ _, err := db.Exec(
+ "INSERT INTO users ("+userColumns+") VALUES (?, ?, ?, ?)",
+ id, username, now, now,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return &User{ID: id, Username: username, CreatedAt: now, LastSeen: now}, nil
+}
+
+func GetUserByID(db *sql.DB, id string) (*User, error) {
+ return scanUser(db.QueryRow("SELECT "+userColumns+" FROM users WHERE id = ?", id))
+}
+
+func RenameUser(db *sql.DB, userID, username string) error {
+ _, err := db.Exec("UPDATE users SET username = ? WHERE id = ?", username, userID)
+ return err
+}
+
+func ListUsers(db *sql.DB) ([]User, error) {
+ rows, err := db.Query("SELECT " + userColumns + " FROM users ORDER BY last_seen DESC")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ users := []User{}
+ for rows.Next() {
+ var u User
+ if err := rows.Scan(&u.ID, &u.Username, &u.CreatedAt, &u.LastSeen); err != nil {
+ return nil, err
+ }
+ users = append(users, u)
+ }
+ return users, rows.Err()
+}
+
+func GetUserByUsername(db *sql.DB, username string) (*User, error) {
+ return scanUser(db.QueryRow("SELECT "+userColumns+" FROM users WHERE username = ?", username))
+}
+
+// GetUserByMAC finds the account a hardware address currently belongs to.
+// Columns are alias-qualified because macs also has a last_seen column.
+func GetUserByMAC(db *sql.DB, mac string) (*User, error) {
+ return scanUser(db.QueryRow(
+ "SELECT u.id, u.username, u.created_at, u.last_seen FROM users u JOIN macs m ON m.user_id = u.id WHERE m.mac = ?",
+ mac,
+ ))
+}
+
+// TouchUser bumps a user's last_seen β€” this is the timestamp the portal shows
+// when someone tries to reclaim a taken handle.
+func TouchUser(db *sql.DB, id string) error {
+ _, err := db.Exec("UPDATE users SET last_seen = ? WHERE id = ?", time.Now(), id)
+ return err
+}
+
+// AssociateMAC binds a MAC to a user, moving it from any previous owner. This is
+// what "logging back in" from a rotated MAC does.
+func AssociateMAC(db *sql.DB, mac, userID string) error {
+ now := time.Now()
+ _, err := db.Exec(`INSERT INTO macs (mac, user_id, first_seen, last_seen) VALUES (?, ?, ?, ?)
+ ON CONFLICT(mac) DO UPDATE SET user_id = excluded.user_id, last_seen = excluded.last_seen`,
+ mac, userID, now, now)
+ return err
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..15ed47a
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,16 @@
+module penguins.lan
+
+go 1.26
+
+require (
+ github.com/go-co-op/gocron v1.37.0
+ github.com/gorilla/websocket v1.5.3
+ github.com/joho/godotenv v1.5.1
+ github.com/mattn/go-sqlite3 v1.14.22
+)
+
+require (
+ github.com/google/uuid v1.4.0 // indirect
+ github.com/robfig/cron/v3 v3.0.1 // indirect
+ go.uber.org/atomic v1.9.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e75db17
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,44 @@
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
+github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
+github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
+github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
+github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..5d6f599
--- /dev/null
+++ b/main.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+ "log"
+
+ "github.com/joho/godotenv"
+
+ "penguins.lan/api"
+ "penguins.lan/args"
+ "penguins.lan/database"
+ "penguins.lan/scheduled"
+)
+
+func main() {
+ log.SetFlags(log.LstdFlags | log.Lshortfile)
+
+ if err := godotenv.Load(); err != nil {
+ log.Println("no .env file loaded:", err)
+ }
+
+ argv, err := args.GetArgs()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ dbConn := database.MakeConn(&argv.DatabasePath)
+ defer dbConn.Close()
+
+ if argv.Migrate {
+ if _, err := database.Migrate(dbConn); err != nil {
+ log.Fatal(err)
+ }
+ log.Println("database migrated successfully")
+ }
+
+ if argv.Scheduler {
+ scheduled.StartScheduler(argv, dbConn)
+ log.Println("scheduler started")
+ }
+
+ if argv.Server {
+ server := api.MakeServer(argv, dbConn)
+ log.Printf("listening on :%d", argv.Port)
+
+ if err := server.ListenAndServe(); err != nil {
+ log.Fatal(err)
+ }
+ }
+
+ select {}
+}
diff --git a/scheduled/scheduled.go b/scheduled/scheduled.go
new file mode 100644
index 0000000..6eb83e4
--- /dev/null
+++ b/scheduled/scheduled.go
@@ -0,0 +1,86 @@
+package scheduled
+
+import (
+ "database/sql"
+ "encoding/json"
+ "log"
+ "time"
+
+ "github.com/go-co-op/gocron"
+
+ "penguins.lan/adapters/arp"
+ "penguins.lan/adapters/netscan"
+ "penguins.lan/api/minecraft"
+ "penguins.lan/args"
+ "penguins.lan/database"
+)
+
+const sweepTimeout = 1 * time.Second
+
+func StartScheduler(argv *args.Arguments, dbConn *sql.DB) {
+ scheduler := gocron.NewScheduler(time.Local)
+
+ scheduler.Every(1).Seconds().Do(func() {
+ if err := refreshArp(dbConn, arp.SystemProvider{}); err != nil {
+ log.Println("arp refresh failed:", err)
+ }
+ })
+
+ if argv.LanSubnet != "" {
+ go sweep(argv.LanSubnet)
+ scheduler.Every(30).Seconds().Do(func() { sweep(argv.LanSubnet) })
+ }
+
+ if argv.RconAddress != "" {
+ go refreshMinecraft(dbConn, argv.RconAddress, argv.RconPassword)
+ scheduler.Every(10).Seconds().Do(func() {
+ refreshMinecraft(dbConn, argv.RconAddress, argv.RconPassword)
+ })
+ }
+
+ scheduler.Every(500).Seconds().Do(func() {
+ if err := database.DeleteExpiredSessions(dbConn); err != nil {
+ log.Println("session cleanup failed:", err)
+ }
+ })
+
+ scheduler.StartAsync()
+}
+
+func refreshArp(dbConn *sql.DB, provider arp.TableProvider) error {
+ table, err := provider.Fetch()
+ if err != nil {
+ return err
+ }
+
+ ipToMac := make(map[string]string, len(table.AddrToMac))
+ for ip, mac := range table.AddrToMac {
+ ipToMac[ip.Format()] = mac.Format()
+ }
+
+ return database.RefreshConnections(dbConn, ipToMac)
+}
+
+func sweep(subnet string) {
+ if err := netscan.Sweep(subnet, sweepTimeout); err != nil {
+ log.Println("lan sweep failed:", err)
+ }
+}
+
+func refreshMinecraft(dbConn *sql.DB, address, password string) {
+ status, err := minecraft.Query(address, password)
+ if err != nil {
+ log.Println("minecraft query failed:", err)
+ status = minecraft.Status{Online: false}
+ }
+
+ blob, err := json.Marshal(status)
+ if err != nil {
+ log.Println("minecraft marshal failed:", err)
+ return
+ }
+
+ if err := database.SaveMinecraftStatus(dbConn, string(blob)); err != nil {
+ log.Println("minecraft save failed:", err)
+ }
+}
diff --git a/static/css/styles.css b/static/css/styles.css
new file mode 100644
index 0000000..53f7abd
--- /dev/null
+++ b/static/css/styles.css
@@ -0,0 +1,128 @@
+:root {
+ --bg: #0d1b2a;
+ --panel: #1b263b;
+ --ink: #e0e1dd;
+ --muted: #8d99ae;
+ --accent: #48cae4;
+ --accent-2: #ffd166;
+ --error: #ef476f;
+ --ok: #06d6a0;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ line-height: 1.5;
+}
+
+.container {
+ max-width: 760px;
+ margin: 0 auto;
+ padding: 1.5rem 1rem 3rem;
+}
+
+a { color: var(--accent); }
+a:hover { color: var(--accent-2); }
+
+h1 { font-size: 1.4rem; margin: 0 0 .5rem; }
+h2 { color: var(--accent); }
+hr { border: none; border-top: 1px dashed var(--muted); margin: 1rem 0; }
+
+header nav {
+ display: flex;
+ align-items: center;
+ gap: .35rem;
+ flex-wrap: wrap;
+}
+.spacer { flex: 1 1 auto; }
+
+.nick-form {
+ display: flex;
+ align-items: center;
+ gap: .35rem;
+}
+.nick-form label { color: var(--muted); }
+
+input, textarea, button {
+ font: inherit;
+ border: 1px solid var(--muted);
+ background: var(--panel);
+ color: var(--ink);
+ padding: .35rem .5rem;
+}
+input:focus, textarea:focus { outline: 2px solid var(--accent); }
+button {
+ cursor: pointer;
+ background: var(--accent);
+ color: var(--bg);
+ border-color: var(--accent);
+ font-weight: bold;
+}
+button:hover { background: var(--accent-2); border-color: var(--accent-2); }
+
+.marquee { color: var(--accent-2); background: var(--panel); padding: .25rem; }
+
+pre {
+ background: var(--panel);
+ padding: .75rem;
+ overflow-x: auto;
+}
+
+.info { padding: .5rem .75rem; margin: .5rem 0; }
+.success { background: rgba(6, 214, 160, .15); border: 1px solid var(--ok); }
+.error { background: rgba(239, 71, 111, .15); border: 1px solid var(--error); }
+
+.status { color: var(--muted); }
+.status.ok { color: var(--ok); }
+
+/* chat */
+.chat-log {
+ height: 20vh;
+ overflow-y: auto;
+ background: var(--panel);
+ padding: .75rem;
+ margin-bottom: .75rem;
+}
+.chat-line { margin: .15rem 0; word-wrap: break-word; }
+.chat-line.mine .chat-nick { color: var(--accent-2); }
+.chat-nick { color: var(--accent); font-weight: bold; }
+.chat-time { color: var(--muted); font-size: .75rem; }
+.chat-system { color: var(--muted); font-style: italic; margin: .25rem 0; }
+.chat-form { display: flex; gap: .5rem; }
+.chat-form input { flex: 1 1 auto; }
+
+/* guestbook */
+.guestbook-form { display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; }
+.guestbook-form textarea { width: 100%; min-height: 4rem; }
+.guestbook-entries { list-style: none; padding: 0; }
+.guestbook-entries li { background: var(--panel); padding: .5rem .75rem; margin: .5rem 0; }
+.entry-message { white-space: pre-wrap; word-wrap: break-word; }
+.entry-meta { color: var(--muted); font-size: .8rem; margin-top: .25rem; }
+
+/* portal */
+.portal-form { display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; margin: .75rem 0; }
+.rename-form { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin: .5rem 0; }
+.roster { list-style: none; padding: 0; }
+.roster li { padding: .15rem 0; }
+.legend { color: var(--muted); font-size: .85rem; display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; }
+.dot { display: inline-block; width: .6rem; height: .6rem; border-radius: 50%; background: var(--muted); vertical-align: middle; }
+.dot.chat { background: var(--ok); }
+.dot.network { background: var(--accent-2); }
+.dot.offline { background: var(--muted); }
+.whoami { color: var(--accent-2); font-weight: bold; }
+.whoami:hover { color: var(--accent); }
+.muted { color: var(--muted); }
+code { background: var(--panel); padding: .1rem .3rem; }
+
+/* canvas / r-place */
+.palette { display: flex; flex-wrap: wrap; gap: 4px; margin: .5rem 0; }
+.swatch { width: 26px; height: 26px; border: 2px solid var(--panel); border-radius: 4px; cursor: pointer; }
+.swatch.selected { border-color: var(--ink); }
+.canvas-wrap { overflow: auto; max-width: 100%; }
+#canvas { image-rendering: pixelated; cursor: crosshair; border: 2px solid var(--muted); touch-action: none; }
+
+footer { color: var(--muted); }
diff --git a/static/img/favicon.svg b/static/img/favicon.svg
new file mode 100644
index 0000000..9e00805
--- /dev/null
+++ b/static/img/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
+ <rect width="100" height="100" rx="18" fill="#0d1b2a"/>
+ <text x="50" y="50" font-size="58" text-anchor="middle" dominant-baseline="central">🐧</text>
+</svg>
diff --git a/static/img/lab.png b/static/img/lab.png
new file mode 100644
index 0000000..dfd0e3d
--- /dev/null
+++ b/static/img/lab.png
Binary files differ
diff --git a/static/js/canvas.js b/static/js/canvas.js
new file mode 100644
index 0000000..f164803
--- /dev/null
+++ b/static/js/canvas.js
@@ -0,0 +1,97 @@
+(function () {
+ const cfg = window.CANVAS;
+ const canvas = document.getElementById("canvas");
+ const ctx = canvas.getContext("2d");
+ canvas.style.width = cfg.cols * cfg.scale + "px";
+ canvas.style.height = cfg.rows * cfg.scale + "px";
+
+ // palette picker
+ let selected = 0;
+ const paletteEl = document.getElementById("palette");
+ cfg.palette.forEach((hex, i) => {
+ const sw = document.createElement("div");
+ sw.className = "swatch" + (i === 0 ? " selected" : "");
+ sw.style.background = hex;
+ sw.title = hex;
+ sw.addEventListener("click", () => {
+ selected = i;
+ [...paletteEl.children].forEach((c) => c.classList.remove("selected"));
+ sw.classList.add("selected");
+ });
+ paletteEl.appendChild(sw);
+ });
+
+ const hexFromInt = (c) => "#" + c.toString(16).padStart(6, "0");
+ const drawPixel = (x, y, hex) => {
+ ctx.fillStyle = hex;
+ ctx.fillRect(x, y, 1, 1);
+ };
+
+ // The server is authoritative: we only paint pixels it broadcasts back. Open
+ // the socket first and queue messages until the initial state is painted, so
+ // nothing placed during the load is lost.
+ let loaded = false;
+ const queue = [];
+ function apply(msg) {
+ if (msg.type === "place") drawPixel(msg.x, msg.y, hexFromInt(msg.c));
+ else if (msg.type === "cooldown") startCooldown(msg.ms);
+ }
+
+ let ws;
+ function connect() {
+ const proto = location.protocol === "https:" ? "wss" : "ws";
+ ws = new WebSocket(`${proto}://${location.host}/canvas/ws`);
+ ws.onmessage = (e) => {
+ const msg = JSON.parse(e.data);
+ loaded ? apply(msg) : queue.push(msg);
+ };
+ ws.onclose = () => setTimeout(connect, 1000);
+ ws.onerror = () => ws.close();
+ }
+ connect();
+
+ fetch("/canvas/state")
+ .then((r) => r.arrayBuffer())
+ .then((buf) => {
+ const bytes = new Uint8Array(buf);
+ const img = ctx.createImageData(cfg.cols, cfg.rows);
+ for (let p = 0; p < cfg.cols * cfg.rows; p++) {
+ img.data[p * 4] = bytes[p * 3];
+ img.data[p * 4 + 1] = bytes[p * 3 + 1];
+ img.data[p * 4 + 2] = bytes[p * 3 + 2];
+ img.data[p * 4 + 3] = 255;
+ }
+ ctx.putImageData(img, 0, 0);
+ loaded = true;
+ queue.forEach(apply);
+ queue.length = 0;
+ });
+
+ // cooldown indicator (server enforces; this is UI feedback)
+ let cooldownUntil = 0;
+ const cdEl = document.getElementById("cooldown");
+ function startCooldown(ms) {
+ cooldownUntil = Math.max(cooldownUntil, Date.now() + ms);
+ tick();
+ }
+ function tick() {
+ const rem = cooldownUntil - Date.now();
+ if (rem > 0) {
+ cdEl.textContent = `cooldown: ${(rem / 1000).toFixed(1)}s`;
+ requestAnimationFrame(tick);
+ } else {
+ cdEl.textContent = "";
+ }
+ }
+
+ canvas.addEventListener("click", (e) => {
+ if (Date.now() < cooldownUntil) return;
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ const rect = canvas.getBoundingClientRect();
+ const x = Math.floor(((e.clientX - rect.left) / rect.width) * cfg.cols);
+ const y = Math.floor(((e.clientY - rect.top) / rect.height) * cfg.rows);
+ if (x < 0 || x >= cfg.cols || y < 0 || y >= cfg.rows) return;
+ ws.send(JSON.stringify({ type: "place", x, y, i: selected }));
+ startCooldown(cfg.cooldownMs);
+ });
+})();
diff --git a/static/js/chat.js b/static/js/chat.js
new file mode 100644
index 0000000..69c169c
--- /dev/null
+++ b/static/js/chat.js
@@ -0,0 +1,101 @@
+(function () {
+ const log = document.getElementById("log");
+ const form = document.getElementById("chat-form");
+ const input = document.getElementById("msg");
+ const status = document.getElementById("status");
+ const me = document.getElementById("me").textContent.trim();
+
+ let socket;
+ let reconnectDelay = 500;
+
+ function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+ }
+
+ function atBottom() {
+ return log.scrollHeight - log.scrollTop - log.clientHeight < 40;
+ }
+
+ function append(node) {
+ const stick = atBottom();
+ log.appendChild(node);
+ if (stick) log.scrollTop = log.scrollHeight;
+ }
+
+ function fmtTime(ms) {
+ if (!ms) return "";
+ const d = new Date(ms);
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ }
+
+ function renderChat(msg) {
+ const line = el("div", "chat-line");
+ if (msg.nick === me) line.classList.add("mine");
+ line.appendChild(el("span", "chat-nick", msg.nick));
+ line.appendChild(el("span", "chat-body", " " + msg.body));
+ line.appendChild(el("span", "chat-time", " " + fmtTime(msg.at)));
+ append(line);
+ }
+
+ function renderSystem(msg) {
+ append(el("div", "chat-system", msg.body));
+ }
+
+ function handle(msg) {
+ switch (msg.type) {
+ case "history":
+ log.innerHTML = "";
+ (msg.history || []).forEach(renderChat);
+ break;
+ case "chat":
+ renderChat(msg);
+ break;
+ case "system":
+ renderSystem(msg);
+ break;
+ }
+ }
+
+ function connect() {
+ const proto = location.protocol === "https:" ? "wss" : "ws";
+ socket = new WebSocket(`${proto}://${location.host}/ws`);
+
+ socket.onopen = function () {
+ status.textContent = "connected βœ“";
+ status.className = "status ok";
+ reconnectDelay = 500;
+ };
+
+ socket.onmessage = function (event) {
+ try {
+ handle(JSON.parse(event.data));
+ } catch (e) {
+ console.error("bad message", e);
+ }
+ };
+
+ socket.onclose = function () {
+ status.textContent = "reconnecting…";
+ status.className = "status";
+ setTimeout(connect, reconnectDelay);
+ reconnectDelay = Math.min(reconnectDelay * 2, 8000);
+ };
+
+ socket.onerror = function () {
+ socket.close();
+ };
+ }
+
+ form.addEventListener("submit", function (e) {
+ e.preventDefault();
+ const body = input.value.trim();
+ if (!body || !socket || socket.readyState !== WebSocket.OPEN) return;
+ socket.send(JSON.stringify({ type: "chat", body: body }));
+ input.value = "";
+ });
+
+ connect();
+})();
diff --git a/static/js/minecraft.js b/static/js/minecraft.js
new file mode 100644
index 0000000..757053c
--- /dev/null
+++ b/static/js/minecraft.js
@@ -0,0 +1,30 @@
+(function () {
+ const el = document.getElementById("mc-status");
+ if (!el) return;
+
+ function escape(s) {
+ const d = document.createElement("div");
+ d.textContent = s;
+ return d.innerHTML;
+ }
+
+ async function poll() {
+ try {
+ const r = await fetch("/minecraft", { headers: { Accept: "application/json" } });
+ if (!r.ok) return;
+ const s = await r.json();
+ if (!s.online) {
+ el.textContent = "server offline";
+ } else if (s.count === 0) {
+ el.textContent = `online β€” 0/${s.max} players`;
+ } else {
+ el.innerHTML = `online β€” ${s.count}/${s.max}: ` + s.players.map(escape).join(", ");
+ }
+ } catch (e) {
+ /* keep the last render */
+ }
+ }
+
+ poll();
+ setInterval(poll, 10000);
+})();
diff --git a/static/js/waddlers.js b/static/js/waddlers.js
new file mode 100644
index 0000000..d1dbd63
--- /dev/null
+++ b/static/js/waddlers.js
@@ -0,0 +1,30 @@
+(function () {
+ const roster = document.getElementById("roster");
+ if (!roster) return;
+
+ function escape(s) {
+ const d = document.createElement("div");
+ d.textContent = s;
+ return d.innerHTML;
+ }
+
+ function render(list) {
+ roster.innerHTML = list
+ .map(
+ (w) =>
+ `<li><span class="dot ${w.status}"></span> <strong>${escape(w.username)}</strong> <span class="muted">β€” last seen ${escape(w.lastSeen)}</span></li>`
+ )
+ .join("");
+ }
+
+ async function poll() {
+ try {
+ const r = await fetch("/waddlers", { headers: { Accept: "application/json" } });
+ if (r.ok) render(await r.json());
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ setInterval(poll, 2000);
+})();
diff --git a/templates/404.html b/templates/404.html
new file mode 100644
index 0000000..e4bb5e2
--- /dev/null
+++ b/templates/404.html
@@ -0,0 +1,4 @@
+{{ define "content" }}
+<h2>404 β€” lost on the ice</h2>
+<p><em>this page waddled off somewhere. <a href="/">head back home</a>.</em></p>
+{{ end }}
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..3a9f582
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,46 @@
+{{ define "base" }}
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <title>penguins.lan~</title>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <link rel="icon" href="/static/img/favicon.svg">
+ <link rel="stylesheet" type="text/css" href="/static/css/styles.css">
+ </head>
+ <body>
+ <div id="content" class="container">
+ <header>
+ <h1>~penguins.lan</h1>
+ <nav aria-label="Main">
+ <a href="/">home.</a>
+ <span> | </span>
+ <a href="/canvas">place.</a>
+ {{ if .User }}
+ <span class="spacer"></span>
+ <span>🐧 <a style="text-decoration: none;" class="whoami" href="/profile"><span id="me">{{ .User.Username }}</span></a></span>
+ <span> | </span>
+ <a href="/logout">logout.</a>
+ {{ end }}
+ </nav>
+ </header>
+ <hr>
+
+ <main>
+ {{ if and .Success (gt (len .Success.Messages) 0) }}
+ {{ range $message := .Success.Messages }}
+ <div class="info success">{{ $message }}</div>
+ {{ end }}
+ {{ end }}
+ {{ if and .Error (gt (len .Error.Messages) 0) }}
+ {{ range $error := .Error.Messages }}
+ <div class="info error">{{ $error }}</div>
+ {{ end }}
+ {{ end }}
+
+ {{ template "content" . }}
+ </main>
+ </div>
+ </body>
+</html>
+{{ end }}
diff --git a/templates/base_empty.html b/templates/base_empty.html
new file mode 100644
index 0000000..adda790
--- /dev/null
+++ b/templates/base_empty.html
@@ -0,0 +1,3 @@
+{{ define "base" }}
+ {{ template "content" . }}
+{{ end }}
diff --git a/templates/canvas.html b/templates/canvas.html
new file mode 100644
index 0000000..84e964d
--- /dev/null
+++ b/templates/canvas.html
@@ -0,0 +1,22 @@
+{{ define "content" }}
+<h2>~place</h2>
+<p>{{ .Canvas.Rows }}Γ—{{ .Canvas.Cols }}, one pixel at a time. pick a color, click a cell. be nice to your fellow waddlers &lt;3</p>
+
+<div id="palette" class="palette"></div>
+
+<div class="canvas-wrap">
+ <canvas id="canvas" width="{{ .Canvas.Cols }}" height="{{ .Canvas.Rows }}"></canvas>
+</div>
+<p id="cooldown" class="muted"></p>
+
+<script>
+window.CANVAS = {
+ rows: {{ .Canvas.Rows }},
+ cols: {{ .Canvas.Cols }},
+ scale: {{ .Canvas.Scale }},
+ cooldownMs: {{ .Canvas.CooldownMs }},
+ palette: [{{ range $i, $c := .Canvas.Palette }}{{ if $i }}, {{ end }}"{{ $c }}"{{ end }}]
+};
+</script>
+<script src="/static/js/canvas.js"></script>
+{{ end }}
diff --git a/templates/home.html b/templates/home.html
new file mode 100644
index 0000000..aeb2739
--- /dev/null
+++ b/templates/home.html
@@ -0,0 +1,53 @@
+{{ define "content" }}
+<h2>~welcome</h2>
+
+<p>hey there penguin! or, <strong>{{ .User.Username }}</strong>, i guess.</p>
+<p>this network does not have access to the outside internet. everything runs off of this battery, phone, and mini AP; i carry it around sometimes.</p>
+
+<div style="text-align: center" >
+ <img width="60%" src="/static/img/lab.png">
+</div>
+
+<p>wanna fuck up this website? the code is available to you to fuck up. go crazy (put in a reverse shell if you want for all i care), but please be courteous &lt;3.</p>
+<pre>
+git clone you@penguins.lan:~/penguins.lan
+cd penguins.lan
+vim templates/home.html
+git add . && git commit -m "HAHAHA"
+git push
+</pre>
+
+<marquee class="marquee">* LONG LIVE THE MARQUEE TAG * THEY CAN'T TAKE IT FROM US *</marquee>
+
+<hr>
+
+<h3>~waddlers</h3>
+<p class="legend">
+ <span class="dot chat"></span> chatting
+ <span class="dot network"></span> online
+ <span class="dot offline"></span> offline
+</p>
+<ul class="roster" id="roster">
+ {{ range .Users }}
+ <li><span class="dot {{ .Status }}"></span> <strong>{{ .Username }}</strong> <span class="muted">β€” last seen {{ .LastSeen }}</span></li>
+ {{ end }}
+</ul>
+<script src="/static/js/waddlers.js"></script>
+
+<div id="log" class="chat-log" aria-live="polite"></div>
+
+<form id="chat-form" class="chat-form" autocomplete="off">
+ <input id="msg" name="msg" placeholder="hello fellow penguins!" maxlength="500" autofocus>
+ <button type="submit">send</button>
+</form>
+
+<hr>
+
+<h3>~minecraft</h3>
+<p>java edition β€” join <code>penguins.lan</code></p>
+<p id="mc-status" class="muted">checking…</p>
+<script src="/static/js/minecraft.js"></script>
+
+
+<script src="/static/js/chat.js"></script>
+{{ end }}
diff --git a/templates/portal.html b/templates/portal.html
new file mode 100644
index 0000000..a2959f1
--- /dev/null
+++ b/templates/portal.html
@@ -0,0 +1,66 @@
+{{ define "content" }}
+<p>welcome to ~penguins.lan</p>
+
+{{ with .Portal }}
+ {{ if .Taken }}
+ <div class="info">
+ <form action="/portal" method="post">
+ <p><strong>{{ .RequestedName }}</strong> is already taken, last seen <strong>{{ .LastSeen }}</strong>. is that you?</p>
+ <input type="hidden" name="username" value="{{ .RequestedName }}">
+ <input type="hidden" name="reclaim" value="1">
+ <button>yes, i am {{ .RequestedName }}</button>
+ </form>
+ </div>
+ {{ end }}
+ <br>
+ <form action="/portal" method="post" id="loginForm">
+ <label for="username">{{ if .Taken }} no? {{ end }} i want to be called</label>
+ <input id="username" name="username" placeholder="{{ .SuggestedName }}" maxlength="24" autofocus>
+ <button type="submit">enter</button>
+ </form>
+
+ {{ if .DetectedMAC }}
+ <p class="muted"><small>this device: <code>{{ .DetectedMAC }}</code>.</small></p>
+ {{ end }}
+{{ end }}
+{{ end }}
+
+<script>
+ const urlParamsText = window.location.href.split('?')[1];
+ const urlParams = new URLSearchParams(urlParamsText);
+ const paramsObj = Object.fromEntries(urlParams.entries());
+ if (paramsObject['_token'] && paramsObject['_authaction']) {
+ const form = document.getElementById("loginForm");
+ const isEmpty = (formData, keys) => {
+ const obj = Object.fromEntries(formData.entries());
+ if (keys === undefined) keys = Object.keys(obj);
+ return keys.every(key => !obj[key]);
+ }
+ const send = async () => {
+ await fetch(paramsObject['_authaction'] + '?tok' + paramsObject['_token'], {
+ mode: "no-cors"
+ });
+ const data = new FormData(form);
+ await fetch(form.action, {
+ method: form.method,
+ body: data
+ });
+ };
+ form.addEventListener("submit", (e) => {
+ e.preventDefault();
+ send().then(() => { window.location.href = "/"; });
+ })
+
+ }
+ function getQueryVariable(variable) {
+ query = window.location.search.substring(1);
+ vars = query.split("&");
+ for (var i=0;i<vars.length;i++) {
+ var pair = vars[i].split("=");
+ if (pair[0] == variable) {
+ return pair[1];
+ }
+ }
+ alert('Query Variable ' + variable + ' not found');
+ }
+</script> \ No newline at end of file
diff --git a/templates/profile.html b/templates/profile.html
new file mode 100644
index 0000000..f0f93d9
--- /dev/null
+++ b/templates/profile.html
@@ -0,0 +1,8 @@
+hellow
+{{ define "content" }}
+<form action="/rename" method="post" class="rename-form">
+ <label for="username">not your name? change it:</label>
+ <input id="username" name="username" value="{{ .User.Username }}" maxlength="24">
+ <button type="submit">rename</button>
+</form>
+{{ end }} \ No newline at end of file
diff --git a/templates/waddlers.html b/templates/waddlers.html
new file mode 100644
index 0000000..69d70bd
--- /dev/null
+++ b/templates/waddlers.html
@@ -0,0 +1,3 @@
+{{ range .Users }}
+<li><strong>{{ .Username }}</strong> <span class="muted">β€” last seen {{ .LastSeen }}</span></li>
+{{ end }} \ No newline at end of file
diff --git a/utils/net.go b/utils/net.go
new file mode 100644
index 0000000..76454bd
--- /dev/null
+++ b/utils/net.go
@@ -0,0 +1,59 @@
+package utils
+
+import (
+ "fmt"
+ "strings"
+)
+
+// Mac is a 48-bit hardware address.
+type Mac struct {
+ Addr [6]byte
+}
+
+// Format renders the canonical lowercase, zero-padded form aa:bb:cc:dd:ee:ff.
+func (m *Mac) Format() string {
+ return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
+ m.Addr[0], m.Addr[1], m.Addr[2], m.Addr[3], m.Addr[4], m.Addr[5])
+}
+
+// ParseMac reads a colon-separated hex MAC. It tolerates uppercase and the
+// zero-stripped octets macOS prints (e.g. "8:0:27:ab:cd:ef").
+func ParseMac(s string) (*Mac, error) {
+ mac := &Mac{}
+ t := strings.TrimSpace(s)
+ n, err := fmt.Sscanf(t, "%x:%x:%x:%x:%x:%x",
+ &mac.Addr[0], &mac.Addr[1], &mac.Addr[2], &mac.Addr[3], &mac.Addr[4], &mac.Addr[5])
+ if err != nil {
+ return nil, err
+ }
+ if n != 6 {
+ return nil, fmt.Errorf("invalid mac %q: parsed %d of 6 octets", s, n)
+ }
+ return mac, nil
+}
+
+// INet4 is an IPv4 address.
+type INet4 struct {
+ Addr [4]byte
+}
+
+// Format renders the dotted-decimal form a.b.c.d.
+func (n *INet4) Format() string {
+ return fmt.Sprintf("%d.%d.%d.%d", n.Addr[0], n.Addr[1], n.Addr[2], n.Addr[3])
+}
+
+// ParseINet4 reads a dotted-decimal IPv4 address. Out-of-range octets (>255)
+// overflow the byte and are rejected.
+func ParseINet4(s string) (*INet4, error) {
+ addr := &INet4{}
+ t := strings.TrimSpace(s)
+ n, err := fmt.Sscanf(t, "%d.%d.%d.%d",
+ &addr.Addr[0], &addr.Addr[1], &addr.Addr[2], &addr.Addr[3])
+ if err != nil {
+ return nil, err
+ }
+ if n != 4 {
+ return nil, fmt.Errorf("invalid ipv4 %q: parsed %d of 4 octets", s, n)
+ }
+ return addr, nil
+}
diff --git a/utils/net_test.go b/utils/net_test.go
new file mode 100644
index 0000000..27d991d
--- /dev/null
+++ b/utils/net_test.go
@@ -0,0 +1,63 @@
+package utils
+
+import "testing"
+
+func TestParseMacAndFormat(t *testing.T) {
+ // each input should parse and round-trip to the canonical form
+ canonical := map[string]string{
+ "aa:bb:cc:dd:ee:ff": "aa:bb:cc:dd:ee:ff", // already canonical
+ "AA:BB:CC:DD:EE:FF": "aa:bb:cc:dd:ee:ff", // uppercase
+ "8:0:27:ab:cd:ef": "08:00:27:ab:cd:ef", // macOS zero-stripped octets
+ " de:ad:be:ef:00:01 ": "de:ad:be:ef:00:01", // surrounding space
+ }
+ for in, want := range canonical {
+ mac, err := ParseMac(in)
+ if err != nil {
+ t.Errorf("ParseMac(%q) unexpected error: %v", in, err)
+ continue
+ }
+ if got := mac.Format(); got != want {
+ t.Errorf("ParseMac(%q).Format() = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestParseMacInvalid(t *testing.T) {
+ for _, in := range []string{
+ "", // empty
+ "aa:bb:cc:dd:ee", // too few octets
+ "hello", // not a mac
+ "zz:zz:zz:zz:zz:zz", // non-hex digits
+ } {
+ if _, err := ParseMac(in); err == nil {
+ t.Errorf("ParseMac(%q) = nil error, want error", in)
+ }
+ }
+}
+
+func TestParseINet4AndFormat(t *testing.T) {
+ for _, ip := range []string{"192.168.1.5", "10.0.0.2", "0.0.0.0", "255.255.255.255"} {
+ addr, err := ParseINet4(ip)
+ if err != nil {
+ t.Errorf("ParseINet4(%q) unexpected error: %v", ip, err)
+ continue
+ }
+ if got := addr.Format(); got != ip {
+ t.Errorf("ParseINet4(%q).Format() = %q, want %q", ip, got, ip)
+ }
+ }
+}
+
+func TestParseINet4Invalid(t *testing.T) {
+ for _, in := range []string{
+ "", // empty
+ "1.2.3", // too few octets
+ "192.168.1.256", // octet overflows a byte
+ "::1", // IPv6, not dotted-decimal
+ "hello", // garbage
+ } {
+ if _, err := ParseINet4(in); err == nil {
+ t.Errorf("ParseINet4(%q) = nil error, want error", in)
+ }
+ }
+}
diff --git a/utils/random_id.go b/utils/random_id.go
new file mode 100644
index 0000000..e6dc731
--- /dev/null
+++ b/utils/random_id.go
@@ -0,0 +1,15 @@
+package utils
+
+import (
+ "crypto/rand"
+ "fmt"
+)
+
+// RandomId returns a 128-bit hex id, handy for sessions, db rows, etc.
+func RandomId() string {
+ id := make([]byte, 16)
+ if _, err := rand.Read(id); err != nil {
+ panic(err)
+ }
+ return fmt.Sprintf("%x", id)
+}
diff --git a/utils/text.go b/utils/text.go
new file mode 100644
index 0000000..5a79ec9
--- /dev/null
+++ b/utils/text.go
@@ -0,0 +1,49 @@
+package utils
+
+import (
+ "fmt"
+ "math/rand"
+ "strings"
+ "time"
+)
+
+var nameAdjectives = []string{"waddly", "frosty", "noot", "sleepy", "tuxedo", "briny", "squeaky", "blizzard", "pebble", "slippy"}
+var nameNouns = []string{"penguin", "gentoo", "rockhopper", "emperor", "macaroni", "chinstrap", "skua", "krill", "iceberg", "fishstick"}
+
+func SuggestName() string {
+ return nameAdjectives[rand.Intn(len(nameAdjectives))] + "-" + nameNouns[rand.Intn(len(nameNouns))]
+}
+
+func Sanitize(raw string) string {
+ return strings.ReplaceAll(strings.TrimSpace(raw), "\n", " ")
+}
+
+func SanitizeName(raw string) string {
+ clean := strings.ToLower(Sanitize(raw))
+ clean = strings.ReplaceAll(clean, " ", "-")
+ if len(clean) > 24 {
+ clean = clean[:24]
+ }
+ return clean
+}
+
+func FormatSince(t time.Time) string {
+ d := time.Since(t)
+ switch {
+ case d < time.Minute && d.Seconds() < 20:
+ return "just now"
+ case d < time.Hour:
+ return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
+ case d < 24*time.Hour:
+ return fmt.Sprintf("%d hours ago", int(d.Hours()))
+ default:
+ return fmt.Sprintf("%d days ago", int(d.Hours()/24))
+ }
+}
+
+func Truncate(s string, n int) string {
+ if len(s) > n {
+ return s[:n]
+ }
+ return s
+}