diff options
Diffstat (limited to 'api')
| -rw-r--r-- | api/auth/auth.go | 15 | ||||
| -rw-r--r-- | api/canvas/canvas.go | 33 | ||||
| -rw-r--r-- | api/canvas/canvas_test.go | 2 | ||||
| -rw-r--r-- | api/devices/devices.go | 92 | ||||
| -rw-r--r-- | api/dns/dns.go | 147 | ||||
| -rw-r--r-- | api/serve.go | 30 |
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) |
