summaryrefslogtreecommitdiff
path: root/api
diff options
context:
space:
mode:
authorAlexander Hunt <me@liz.coffee>2026-06-21 12:43:31 -0700
committerAlexander Hunt <me@liz.coffee>2026-06-21 12:43:31 -0700
commitcfc0d34a584254a9ed76620951c810771aff6f3b (patch)
tree7fba034f7347ce0003e7a3a56adcaf862c834907 /api
parentfb653292612eddca5b088ed88466c546d1597107 (diff)
downloadpenguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.tar.gz
penguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.zip
STUFF
Diffstat (limited to 'api')
-rw-r--r--api/auth/auth.go15
-rw-r--r--api/canvas/canvas.go33
-rw-r--r--api/canvas/canvas_test.go2
-rw-r--r--api/devices/devices.go92
-rw-r--r--api/dns/dns.go147
-rw-r--r--api/serve.go30
6 files changed, 292 insertions, 27 deletions
diff --git a/api/auth/auth.go b/api/auth/auth.go
index a70af32..78123a6 100644
--- a/api/auth/auth.go
+++ b/api/auth/auth.go
@@ -141,11 +141,20 @@ func RenameContinuation(context *types.RequestContext, req *http.Request, resp h
return renameError("something broke, try again")
}
+ oldName := context.User.Username
if err := database.RenameUser(context.DBConn, context.User.ID, newName); err != nil {
log.Println("rename failed:", err)
return renameError("couldn't rename, try again")
}
+ // Device records are synthesized from the current username and follow
+ // the rename for free; stored records have the old name baked in, so
+ // move them to the new subdomain to keep the user's setup working.
+ zone := context.Args.DnsZone
+ if err := database.RenameUserDNSDomain(context.DBConn, context.User.ID, oldName+"."+zone, newName+"."+zone); err != nil {
+ log.Println("rename dns records failed:", err)
+ }
+
context.User.Username = newName
context.Nick = newName
http.Redirect(resp, req, "/", http.StatusSeeOther)
@@ -272,6 +281,12 @@ func bindAndFinish(context *types.RequestContext, resp http.ResponseWriter, user
if err := database.AssociateMAC(context.DBConn, mac, user.ID); err != nil {
log.Println("associate mac failed:", err)
}
+ // First device a user binds becomes their primary automatically. The
+ // conditional update is atomic, so this stays correct even if two
+ // devices bind concurrently.
+ if err := database.SetPrimaryMACIfUnset(context.DBConn, user.ID, mac); err != nil {
+ log.Println("set primary mac failed:", err)
+ }
}
_ = database.TouchUser(context.DBConn, user.ID)
if err := LoginUser(context, resp, user); err != nil {
diff --git a/api/canvas/canvas.go b/api/canvas/canvas.go
index 717d924..d44dd87 100644
--- a/api/canvas/canvas.go
+++ b/api/canvas/canvas.go
@@ -1,14 +1,13 @@
-// 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 (
+ "compress/gzip"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
+ "strings"
"time"
"github.com/gorilla/websocket"
@@ -18,12 +17,12 @@ import (
)
const (
- Rows = 64
- Cols = 64
+ Rows = 512
+ Cols = 512
Scale = 8
- placeCooldown = 2 * time.Second
- snapshotThreshold = 50
+ placeCooldown = 500 * time.Millisecond
+ snapshotThreshold = 500
defaultColor = 0xffffff
writeWait = 10 * time.Second
@@ -32,7 +31,6 @@ const (
maxMessageSize = 256
)
-// r/place 2017 palette.
var palette = []int{
0xffffff, 0xe4e4e4, 0x888888, 0x222222,
0xffa7d1, 0xe50000, 0xe59500, 0xa06a42,
@@ -61,8 +59,6 @@ type outbound struct {
Ms int64 `json:"ms,omitempty"`
}
-// ---- snapshot / state ------------------------------------------------------
-
func blankCanvas() []byte {
buf := make([]byte, Rows*Cols*3)
for i := 0; i < len(buf); i += 3 {
@@ -83,7 +79,6 @@ func setPixel(buf []byte, x, y, color int) {
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 {
@@ -138,8 +133,6 @@ func takeSnapshot(db *sql.DB) error {
return database.DeleteOldCanvasSnapshots(db)
}
-// ---- hub -------------------------------------------------------------------
-
type Hub struct {
db *sql.DB
clients map[*Client]bool
@@ -246,8 +239,6 @@ func (h *Hub) handlePlace(p placement) {
}
}
-// ---- client ----------------------------------------------------------------
-
type Client struct {
hub *Hub
conn *websocket.Conn
@@ -308,8 +299,6 @@ func (c *Client) writePump() {
}
}
-// ---- continuations ---------------------------------------------------------
-
type pageConfig struct {
Rows int
Cols int
@@ -348,7 +337,15 @@ func StateContinuation(context *types.RequestContext, req *http.Request, resp ht
return success(context, req, resp)
}
resp.Header().Set("Content-Type", "application/octet-stream")
- resp.Write(buf)
+ // mostly-blank canvas, so it gzips down to almost nothing.
+ if strings.Contains(req.Header.Get("Accept-Encoding"), "gzip") {
+ resp.Header().Set("Content-Encoding", "gzip")
+ gz := gzip.NewWriter(resp)
+ defer gz.Close()
+ gz.Write(buf)
+ } else {
+ resp.Write(buf)
+ }
return success(context, req, resp)
}
}
diff --git a/api/canvas/canvas_test.go b/api/canvas/canvas_test.go
index 324a3a1..38bb492 100644
--- a/api/canvas/canvas_test.go
+++ b/api/canvas/canvas_test.go
@@ -53,7 +53,6 @@ func TestSnapshotBakesThenReplays(t *testing.T) {
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)
}
@@ -69,7 +68,6 @@ func TestSnapshotBakesThenReplays(t *testing.T) {
t.Errorf("replayed pixel = %06x, want green", got)
}
- // snapshots are kept to one row
if err := takeSnapshot(db); err != nil {
t.Fatal(err)
}
diff --git a/api/devices/devices.go b/api/devices/devices.go
new file mode 100644
index 0000000..227afcb
--- /dev/null
+++ b/api/devices/devices.go
@@ -0,0 +1,92 @@
+package devices
+
+import (
+ "log"
+ "net/http"
+
+ "penguins.lan/api/types"
+ "penguins.lan/database"
+ "penguins.lan/utils"
+)
+
+// DeviceView is one device row for the profile page.
+type DeviceView struct {
+ Mac string // canonical colon form, used as the set-primary value
+ Name string // <compact-mac>.<username>.<zone> FQDN
+ IP string
+ Online bool
+ LastSeen string
+ Primary bool
+}
+
+// ListDevicesContinuation loads the user's devices (with liveness + which is
+// primary) and the zone facts the profile page and wizard need.
+func ListDevicesContinuation(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 failure(context, req, resp)
+ }
+
+ macs, err := database.ListUserMacs(context.DBConn, context.User.ID)
+ if err != nil {
+ log.Println("devices: list failed:", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return failure(context, req, resp)
+ }
+ primary, err := database.GetUserPrimaryMAC(context.DBConn, context.User.ID)
+ if err != nil {
+ log.Println("devices: primary lookup failed:", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return failure(context, req, resp)
+ }
+
+ zone := context.Args.DnsZone
+ userDomain := context.User.Username + "." + zone
+
+ views := make([]DeviceView, 0, len(macs))
+ for _, d := range macs {
+ name := d.Mac
+ if mac, err := utils.ParseMac(d.Mac); err == nil {
+ name = mac.Compact() + "." + userDomain
+ }
+ views = append(views, DeviceView{
+ Mac: d.Mac,
+ Name: name,
+ IP: d.IP,
+ Online: d.Online,
+ LastSeen: utils.FormatSince(d.LastSeen),
+ Primary: d.Mac == primary,
+ })
+ }
+
+ (*context.TemplateData)["Devices"] = views
+ (*context.TemplateData)["PrimaryMAC"] = primary
+ (*context.TemplateData)["DnsZone"] = zone
+ (*context.TemplateData)["UserDomain"] = userDomain
+ return success(context, req, resp)
+ }
+}
+
+// SetPrimaryContinuation marks one of the caller's devices as primary.
+func SetPrimaryContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ mac := req.FormValue("mac")
+ owned, err := database.MACBelongsToUser(context.DBConn, mac, context.User.ID)
+ if err != nil {
+ log.Println("devices: ownership check failed:", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return failure(context, req, resp)
+ }
+ if !owned {
+ resp.WriteHeader(http.StatusUnauthorized)
+ return failure(context, req, resp)
+ }
+ if err := database.SetUserPrimaryMAC(context.DBConn, context.User.ID, mac); err != nil {
+ log.Println("devices: set primary failed:", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return failure(context, req, resp)
+ }
+ http.Redirect(resp, req, "/profile", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+}
diff --git a/api/dns/dns.go b/api/dns/dns.go
new file mode 100644
index 0000000..4cda557
--- /dev/null
+++ b/api/dns/dns.go
@@ -0,0 +1,147 @@
+package dns
+
+import (
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "penguins.lan/api/types"
+ "penguins.lan/database"
+ "penguins.lan/utils"
+)
+
+const MaxUserRecords = 50
+
+var allowedTypes = map[string]bool{"A": true, "AAAA": true, "CNAME": true, "TXT": true}
+
+// ListDNSRecordsContinuation loads the current user's records for the editor.
+func ListDNSRecordsContinuation(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 failure(context, req, resp)
+ }
+ records, err := database.GetUserDNSRecords(context.DBConn, context.User.ID)
+ if err != nil {
+ log.Println("dns: list records failed:", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return failure(context, req, resp)
+ }
+ (*context.TemplateData)["DNSRecords"] = records
+ return success(context, req, resp)
+ }
+}
+
+// CreateDNSRecordContinuation validates and upserts a record. Names are confined
+// to the user's own <username>.<zone> subtree; bare/relative names are expanded
+// to it for convenience.
+func CreateDNSRecordContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ fail := func(msg string, code int) types.ContinuationChain {
+ resp.WriteHeader(code)
+ (*context.TemplateData)["Error"] = types.BannerMessages{Messages: []string{msg}}
+ return failure(context, req, resp)
+ }
+
+ recordType := strings.ToUpper(strings.TrimSpace(req.FormValue("type")))
+ if !allowedTypes[recordType] {
+ return fail("type must be one of A, AAAA, CNAME, TXT", http.StatusBadRequest)
+ }
+
+ name, ok := qualifyName(req.FormValue("name"), context.User.Username, context.Args.DnsZone)
+ if !ok {
+ return fail("name must be under "+context.User.Username+"."+context.Args.DnsZone, http.StatusBadRequest)
+ }
+
+ content := strings.TrimSpace(req.FormValue("content"))
+ if content == "" {
+ return fail("content can't be empty", http.StatusBadRequest)
+ }
+
+ ttl := 300
+ if raw := strings.TrimSpace(req.FormValue("ttl")); raw != "" {
+ n, err := strconv.Atoi(raw)
+ if err != nil || n < 0 {
+ return fail("ttl must be a non-negative number", http.StatusBadRequest)
+ }
+ ttl = n
+ }
+
+ count, err := database.CountUserDNSRecords(context.DBConn, context.User.ID)
+ if err != nil {
+ return fail("something broke, try again", http.StatusInternalServerError)
+ }
+ if count >= MaxUserRecords {
+ return fail("you've hit the record limit", http.StatusTooManyRequests)
+ }
+
+ // A name may hold at most one CNAME; replace any existing one so
+ // repointing the primary (or re-running the wizard) doesn't duplicate.
+ if recordType == "CNAME" {
+ if err := database.DeleteUserRecordsByNameType(context.DBConn, context.User.ID, name, "CNAME"); err != nil {
+ return fail("something broke, try again", http.StatusInternalServerError)
+ }
+ }
+
+ record := &database.DNSRecord{
+ ID: utils.RandomId(),
+ UserID: context.User.ID,
+ Name: name,
+ Type: recordType,
+ Content: content,
+ TTL: ttl,
+ }
+ if _, err := database.SaveDNSRecord(context.DBConn, record); err != nil {
+ log.Println("dns: save record failed:", err)
+ return fail("couldn't save that record", http.StatusInternalServerError)
+ }
+
+ http.Redirect(resp, req, "/profile", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+}
+
+// DeleteDNSRecordContinuation removes a record the caller owns.
+func DeleteDNSRecordContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ record, err := database.GetDNSRecord(context.DBConn, req.FormValue("id"))
+ if err != nil {
+ resp.WriteHeader(http.StatusNotFound)
+ return failure(context, req, resp)
+ }
+ if record.UserID != context.User.ID {
+ resp.WriteHeader(http.StatusUnauthorized)
+ return failure(context, req, resp)
+ }
+ if err := database.DeleteDNSRecord(context.DBConn, record.ID); err != nil {
+ log.Println("dns: delete record failed:", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return failure(context, req, resp)
+ }
+ http.Redirect(resp, req, "/profile", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+}
+
+// qualifyName normalizes a record name to a fully-qualified name (trailing dot)
+// confined to the user's <username>.<zone> subtree. A name already under the
+// subtree is kept; a name elsewhere in the zone is rejected; anything else is
+// treated as relative to the subtree (so "www" -> "www.<user>.<zone>.").
+func qualifyName(raw, username, zone string) (string, bool) {
+ raw = strings.ToLower(strings.TrimSpace(raw))
+ raw = strings.TrimSuffix(raw, ".")
+ if raw == "" {
+ return "", false
+ }
+ zone = strings.ToLower(zone)
+ userDomain := username + "." + zone
+
+ switch {
+ case raw == userDomain || strings.HasSuffix(raw, "."+userDomain):
+ return raw + ".", true
+ case raw == zone || strings.HasSuffix(raw, "."+zone):
+ return "", false // in the zone but not the user's subtree
+ default:
+ return raw + "." + userDomain + ".", true
+ }
+}
diff --git a/api/serve.go b/api/serve.go
index 0005369..dad5009 100644
--- a/api/serve.go
+++ b/api/serve.go
@@ -9,6 +9,8 @@ import (
"penguins.lan/api/auth"
"penguins.lan/api/canvas"
+ "penguins.lan/api/devices"
+ "penguins.lan/api/dns"
"penguins.lan/api/minecraft"
"penguins.lan/api/presence"
"penguins.lan/api/template"
@@ -99,7 +101,26 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server {
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)
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(devices.ListDevicesContinuation, auth.GoPortalContinuation)(dns.ListDNSRecordsContinuation, FailurePassingContinuation)(template.TemplateContinuation("profile.html", true), auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("POST /profile/primary", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(devices.SetPrimaryContinuation, auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /dns", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/profile", http.StatusSeeOther)
+ })
+
+ mux.HandleFunc("POST /dns", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(devices.ListDevicesContinuation, auth.GoPortalContinuation)(dns.ListDNSRecordsContinuation, auth.GoPortalContinuation)(dns.CreateDNSRecordContinuation, auth.GoPortalContinuation)(IdContinuation, template.TemplateContinuation("profile.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("POST /dns/delete", func(w http.ResponseWriter, r *http.Request) {
+ ctx := makeRequestContext()
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(devices.ListDevicesContinuation, auth.GoPortalContinuation)(dns.ListDNSRecordsContinuation, auth.GoPortalContinuation)(dns.DeleteDNSRecordContinuation, auth.GoPortalContinuation)(IdContinuation, template.TemplateContinuation("profile.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
})
mux.HandleFunc("GET /logout", func(w http.ResponseWriter, r *http.Request) {
@@ -109,7 +130,7 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server {
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)
+ LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(presence.RosterContinuation(hub), FailurePassingContinuation)(canvas.PageContinuation, 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) {
@@ -122,11 +143,6 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server {
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)