server: emoji reactions and encrypted file uploads
- reactions table + add/remove/count/forMessages; WS react/unreact broadcast updated counts; /api/messages includes [{emoji,count,mine}].
- blobs table + POST /api/upload and GET /api/blob/{id} (token-gated, 10 MB cap); stores opaque encrypted bytes only.
- tests for both; newTestServer now returns the *Server so tests can seed the DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 files changed,
+411
−22
db.go+136 −3db_test.go+42 −0handlers_test.go+104 −7hub.go+53 −12main.go+76 −0
diff --git a/db.go b/db.go| index ea9c1a3..d76fbeb 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -83,6 +83,19 @@ CREATE TABLE IF NOT EXISTS push_subscriptions ( |
| p256dh TEXT NOT NULL, |
| auth TEXT NOT NULL, |
| created_at INTEGER NOT NULL |
| +); |
| +CREATE TABLE IF NOT EXISTS reactions ( |
| + message_id INTEGER NOT NULL, |
| + token TEXT NOT NULL, |
| + emoji TEXT NOT NULL, |
| + created_at INTEGER NOT NULL, |
| + PRIMARY KEY (message_id, token, emoji) |
| +); |
| +CREATE TABLE IF NOT EXISTS blobs ( |
| + id TEXT PRIMARY KEY, |
| + data BLOB NOT NULL, |
| + size INTEGER NOT NULL, |
| + created_at INTEGER NOT NULL |
| );`) |
| return err |
| } |
| @@ -249,9 +262,10 @@ func (db *DB) revokeInvite(token string) error { |
| // ---- messages ----------------------------------------------------------- |
| type Message struct { |
| - ID int64 `json:"id"` |
| - CreatedAt int64 `json:"created_at"` |
| - Blob string `json:"blob"` |
| + ID int64 `json:"id"` |
| + CreatedAt int64 `json:"created_at"` |
| + Blob string `json:"blob"` |
| + Reactions []ReactionView `json:"reactions,omitempty"` |
| } |
| func (db *DB) insertMessage(blob string) (Message, error) { |
| @@ -297,3 +311,122 @@ func (db *DB) messagesAfter(after int64, limit int) ([]Message, error) { |
| } |
| return out, rows.Err() |
| } |
| + |
| +// ---- blobs (encrypted attachments) -------------------------------------- |
| +// |
| +// An uploaded file is encrypted in the browser before it ever reaches here, so |
| +// the server stores only opaque bytes — no filename, type, or content. The |
| +// referencing message (also encrypted) carries the metadata. |
| + |
| +func (db *DB) putBlob(id string, data []byte) error { |
| + _, err := db.sql.Exec( |
| + "INSERT INTO blobs(id, data, size, created_at) VALUES(?, ?, ?, ?)", |
| + id, data, len(data), time.Now().Unix(), |
| + ) |
| + return err |
| +} |
| + |
| +// getBlob returns the stored bytes, or (nil, nil) if the id is unknown. |
| +func (db *DB) getBlob(id string) ([]byte, error) { |
| + var data []byte |
| + err := db.sql.QueryRow("SELECT data FROM blobs WHERE id = ?", id).Scan(&data) |
| + if err == sql.ErrNoRows { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + return data, nil |
| +} |
| + |
| +// ---- reactions ---------------------------------------------------------- |
| +// |
| +// A reaction is (message_id, token, emoji). The emoji is stored in the clear so |
| +// the server can count and de-duplicate them; message content stays encrypted. |
| +// The token identifies the reactor for toggling, never their screen name. |
| + |
| +// ReactionCount is an emoji and how many people used it (for live broadcast). |
| +type ReactionCount struct { |
| + Emoji string `json:"emoji"` |
| + Count int `json:"count"` |
| +} |
| + |
| +// ReactionView adds whether the requesting token is one of the reactors. |
| +type ReactionView struct { |
| + Emoji string `json:"emoji"` |
| + Count int `json:"count"` |
| + Mine bool `json:"mine"` |
| +} |
| + |
| +func (db *DB) addReaction(messageID int64, token, emoji string) error { |
| + _, err := db.sql.Exec( |
| + "INSERT OR IGNORE INTO reactions(message_id, token, emoji, created_at) VALUES(?, ?, ?, ?)", |
| + messageID, token, emoji, time.Now().Unix(), |
| + ) |
| + return err |
| +} |
| + |
| +func (db *DB) removeReaction(messageID int64, token, emoji string) error { |
| + _, err := db.sql.Exec( |
| + "DELETE FROM reactions WHERE message_id = ? AND token = ? AND emoji = ?", |
| + messageID, token, emoji, |
| + ) |
| + return err |
| +} |
| + |
| +// reactionCounts returns the per-emoji totals for one message, oldest emoji first. |
| +func (db *DB) reactionCounts(messageID int64) ([]ReactionCount, error) { |
| + rows, err := db.sql.Query( |
| + "SELECT emoji, COUNT(*) FROM reactions WHERE message_id = ? GROUP BY emoji ORDER BY MIN(created_at)", |
| + messageID, |
| + ) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := []ReactionCount{} |
| + for rows.Next() { |
| + var rc ReactionCount |
| + if err := rows.Scan(&rc.Emoji, &rc.Count); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, rc) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// reactionsForMessages returns per-message reaction views for the given message |
| +// ids, flagging which emojis the supplied token reacted with (for the toggle UI). |
| +func (db *DB) reactionsForMessages(ids []int64, token string) (map[int64][]ReactionView, error) { |
| + out := map[int64][]ReactionView{} |
| + if len(ids) == 0 { |
| + return out, nil |
| + } |
| + ph := make([]string, len(ids)) |
| + args := make([]any, 0, len(ids)+1) |
| + args = append(args, token) // first ? in the CASE expression |
| + for i, id := range ids { |
| + ph[i] = "?" |
| + args = append(args, id) |
| + } |
| + q := "SELECT message_id, emoji, COUNT(*), " + |
| + "SUM(CASE WHEN token = ? THEN 1 ELSE 0 END) " + |
| + "FROM reactions WHERE message_id IN (" + strings.Join(ph, ",") + ") " + |
| + "GROUP BY message_id, emoji ORDER BY MIN(created_at)" |
| + rows, err := db.sql.Query(q, args...) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + for rows.Next() { |
| + var mid int64 |
| + var rv ReactionView |
| + var mine int |
| + if err := rows.Scan(&mid, &rv.Emoji, &rv.Count, &mine); err != nil { |
| + return nil, err |
| + } |
| + rv.Mine = mine > 0 |
| + out[mid] = append(out[mid], rv) |
| + } |
| + return out, rows.Err() |
| +} |
diff --git a/db_test.go b/db_test.go| index abdc56a..baf5087 100644 |
| --- a/db_test.go |
| +++ b/db_test.go |
| @@ -101,6 +101,48 @@ func TestPushSubscriptions(t *testing.T) { |
| } |
| } |
| +func TestReactions(t *testing.T) { |
| + db := newTestDB(t) |
| + m, _ := db.insertMessage("blob") |
| + |
| + // Two people add 👍; one adds ❤️. |
| + db.addReaction(m.ID, "tokA", "👍") |
| + db.addReaction(m.ID, "tokB", "👍") |
| + db.addReaction(m.ID, "tokA", "❤️") |
| + |
| + // Duplicate add is a no-op (composite PK). |
| + db.addReaction(m.ID, "tokA", "👍") |
| + |
| + counts, _ := db.reactionCounts(m.ID) |
| + got := map[string]int{} |
| + for _, c := range counts { |
| + got[c.Emoji] = c.Count |
| + } |
| + if got["👍"] != 2 || got["❤️"] != 1 { |
| + t.Fatalf("counts = %+v", got) |
| + } |
| + |
| + // "mine" is computed per-token. |
| + views, _ := db.reactionsForMessages([]int64{m.ID}, "tokB") |
| + for _, v := range views[m.ID] { |
| + if v.Emoji == "👍" && !v.Mine { |
| + t.Fatal("tokB should own 👍") |
| + } |
| + if v.Emoji == "❤️" && v.Mine { |
| + t.Fatal("tokB should NOT own ❤️") |
| + } |
| + } |
| + |
| + // Removing toggles it off. |
| + db.removeReaction(m.ID, "tokB", "👍") |
| + counts, _ = db.reactionCounts(m.ID) |
| + for _, c := range counts { |
| + if c.Emoji == "👍" && c.Count != 1 { |
| + t.Fatalf("after remove, 👍 count = %d", c.Count) |
| + } |
| + } |
| +} |
| + |
| func TestGroupName(t *testing.T) { |
| db := newTestDB(t) |
| if got := db.getGroupName(); got != defaultGroupName { |
diff --git a/handlers_test.go b/handlers_test.go| index 81fadda..0b2cdb0 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -9,7 +9,7 @@ import ( |
| "testing" |
| ) |
| -func newTestServer(t *testing.T) *httptest.Server { |
| +func newTestServer(t *testing.T) (*httptest.Server, *Server) { |
| t.Helper() |
| db, err := openDB(t.TempDir() + "/test.db") |
| if err != nil { |
| @@ -26,7 +26,7 @@ func newTestServer(t *testing.T) *httptest.Server { |
| s := &Server{db: db, hub: hub, pages: pages, vapidPub: pub, vapidPriv: priv} |
| ts := httptest.NewServer(s.handler()) |
| t.Cleanup(ts.Close) |
| - return ts |
| + return ts, s |
| } |
| // adminPost issues a POST with the admin bearer token. |
| @@ -43,7 +43,7 @@ func adminPost(t *testing.T, url, body string) *http.Response { |
| } |
| func TestAdminAuthGating(t *testing.T) { |
| - ts := newTestServer(t) |
| + ts, _ := newTestServer(t) |
| resp, _ := http.Post(ts.URL+"/api/admin/invites", "application/json", strings.NewReader(`{"label":"x"}`)) |
| if resp.StatusCode != http.StatusUnauthorized { |
| @@ -59,7 +59,7 @@ func TestAdminAuthGating(t *testing.T) { |
| } |
| func TestInviteAndMessagesFlow(t *testing.T) { |
| - ts := newTestServer(t) |
| + ts, _ := newTestServer(t) |
| resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"Sophie"}`) |
| if resp.StatusCode != http.StatusOK { |
| @@ -95,8 +95,105 @@ func TestInviteAndMessagesFlow(t *testing.T) { |
| } |
| } |
| +func TestMessagesIncludeReactions(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"x"}`) |
| + var inv struct { |
| + Token string `json:"token"` |
| + } |
| + json.NewDecoder(resp.Body).Decode(&inv) |
| + |
| + // Seed a message + reactions directly (they normally arrive over the WS). |
| + m, _ := srv.db.insertMessage("ciphertext-blob") |
| + srv.db.addReaction(m.ID, "someoneElse", "👍") |
| + srv.db.addReaction(m.ID, inv.Token, "❤️") |
| + |
| + resp, _ = http.Get(ts.URL + "/api/messages?token=" + inv.Token) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("messages: %d", resp.StatusCode) |
| + } |
| + var out struct { |
| + Messages []struct { |
| + ID int64 `json:"id"` |
| + Reactions []struct { |
| + Emoji string `json:"emoji"` |
| + Count int `json:"count"` |
| + Mine bool `json:"mine"` |
| + } `json:"reactions"` |
| + } `json:"messages"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + if len(out.Messages) != 1 { |
| + t.Fatalf("want 1 message, got %d", len(out.Messages)) |
| + } |
| + seen := map[string]struct{ count int; mine bool }{} |
| + for _, r := range out.Messages[0].Reactions { |
| + seen[r.Emoji] = struct{ count int; mine bool }{r.Count, r.Mine} |
| + } |
| + if seen["👍"].count != 1 || seen["👍"].mine { |
| + t.Fatalf("👍 = %+v (want count 1, mine false)", seen["👍"]) |
| + } |
| + if seen["❤️"].count != 1 || !seen["❤️"].mine { |
| + t.Fatalf("❤️ = %+v (want count 1, mine true)", seen["❤️"]) |
| + } |
| +} |
| + |
| +func TestBlobUploadDownload(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"x"}`) |
| + var inv struct { |
| + Token string `json:"token"` |
| + } |
| + json.NewDecoder(resp.Body).Decode(&inv) |
| + |
| + payload := []byte("pretend-this-is-encrypted-bytes\x00\x01\x02") |
| + |
| + // Upload requires a valid token. |
| + resp, _ = http.Post(ts.URL+"/api/upload?token=bogus", "application/octet-stream", strings.NewReader("x")) |
| + if resp.StatusCode != http.StatusForbidden { |
| + t.Fatalf("upload bad token: want 403, got %d", resp.StatusCode) |
| + } |
| + |
| + resp, err := http.Post(ts.URL+"/api/upload?token="+inv.Token, "application/octet-stream", strings.NewReader(string(payload))) |
| + if err != nil || resp.StatusCode != http.StatusOK { |
| + t.Fatalf("upload: err=%v status=%d", err, resp.StatusCode) |
| + } |
| + var up struct { |
| + ID string `json:"id"` |
| + } |
| + json.NewDecoder(resp.Body).Decode(&up) |
| + if up.ID == "" { |
| + t.Fatal("no blob id returned") |
| + } |
| + |
| + // Download requires a valid token. |
| + resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=bogus") |
| + if resp.StatusCode != http.StatusForbidden { |
| + t.Fatalf("download bad token: want 403, got %d", resp.StatusCode) |
| + } |
| + |
| + // Round-trips exactly. |
| + resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + inv.Token) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("download: %d", resp.StatusCode) |
| + } |
| + got, _ := io.ReadAll(resp.Body) |
| + if string(got) != string(payload) { |
| + t.Fatalf("blob mismatch: got %q", got) |
| + } |
| + |
| + // Unknown id is a 404. |
| + resp, _ = http.Get(ts.URL + "/api/blob/nope?token=" + inv.Token) |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("missing blob: want 404, got %d", resp.StatusCode) |
| + } |
| +} |
| + |
| func TestPushSubscribeGating(t *testing.T) { |
| - ts := newTestServer(t) |
| + ts, _ := newTestServer(t) |
| body := `{"token":"bad","subscription":{"endpoint":"https://e","keys":{"p256dh":"p","auth":"a"}}}` |
| resp, _ := http.Post(ts.URL+"/api/push/subscribe", "application/json", strings.NewReader(body)) |
| if resp.StatusCode != http.StatusForbidden { |
| @@ -105,7 +202,7 @@ func TestPushSubscribeGating(t *testing.T) { |
| } |
| func TestGroupNameEndpoints(t *testing.T) { |
| - ts := newTestServer(t) |
| + ts, _ := newTestServer(t) |
| resp, _ := http.Get(ts.URL + "/api/group") |
| var g struct{ Name string } |
| @@ -139,7 +236,7 @@ func TestGroupNameEndpoints(t *testing.T) { |
| } |
| func TestGroupNameEscapedInHTML(t *testing.T) { |
| - ts := newTestServer(t) |
| + ts, _ := newTestServer(t) |
| adminPost(t, ts.URL+"/api/admin/group", `{"name":"<script>x</script>"}`) |
| resp, _ := http.Get(ts.URL + "/") |
| body, _ := io.ReadAll(resp.Body) |
diff --git a/hub.go b/hub.go| index 360b2c4..f9f90ba 100644 |
| --- a/hub.go |
| +++ b/hub.go |
| @@ -63,8 +63,10 @@ func (h *Hub) run() { |
| } |
| type wsIn struct { |
| - Type string `json:"type"` |
| - Blob string `json:"blob"` |
| + Type string `json:"type"` |
| + Blob string `json:"blob"` |
| + MessageID int64 `json:"message_id"` |
| + Emoji string `json:"emoji"` |
| } |
| type wsOut struct { |
| @@ -74,6 +76,18 @@ type wsOut struct { |
| Blob string `json:"blob"` |
| } |
| +type wsReactionOut struct { |
| + Type string `json:"type"` |
| + MessageID int64 `json:"message_id"` |
| + Reactions []ReactionCount `json:"reactions"` |
| +} |
| + |
| +// allowedReactions is the fixed emoji set the UI offers. Restricting it keeps |
| +// the (plaintext) emoji column to known values — no arbitrary strings. |
| +var allowedReactions = map[string]bool{ |
| + "👍": true, "❤️": true, "😂": true, "😮": true, "😢": true, "🎉": true, |
| +} |
| + |
| func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| token := r.URL.Query().Get("token") |
| exists, revoked, err := s.db.inviteStatus(token) |
| @@ -120,21 +134,48 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| if json.Unmarshal(data, &in) != nil { |
| continue |
| } |
| - if in.Type != "msg" || in.Blob == "" || len(in.Blob) > maxBlobBytes { |
| + if in.Type != "msg" && in.Type != "react" && in.Type != "unreact" { |
| continue |
| } |
| - // Re-check revocation so a revoked client can't keep posting. |
| + // Re-check revocation so a revoked client can't keep posting or reacting. |
| if _, rev, err := s.db.inviteStatus(token); err != nil || rev { |
| return |
| } |
| - m, err := s.db.insertMessage(in.Blob) |
| - if err != nil { |
| - log.Println("insert message:", err) |
| - continue |
| + |
| + switch in.Type { |
| + case "msg": |
| + if in.Blob == "" || len(in.Blob) > maxBlobBytes { |
| + continue |
| + } |
| + m, err := s.db.insertMessage(in.Blob) |
| + if err != nil { |
| + log.Println("insert message:", err) |
| + continue |
| + } |
| + out, _ := json.Marshal(wsOut{Type: "msg", ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob}) |
| + s.hub.broadcast <- out |
| + // Notify subscribers who don't have the app open (best-effort, async). |
| + go s.pushNewMessage(m.Blob) |
| + |
| + case "react", "unreact": |
| + if in.MessageID <= 0 || !allowedReactions[in.Emoji] { |
| + continue |
| + } |
| + if in.Type == "react" { |
| + err = s.db.addReaction(in.MessageID, token, in.Emoji) |
| + } else { |
| + err = s.db.removeReaction(in.MessageID, token, in.Emoji) |
| + } |
| + if err != nil { |
| + log.Println("reaction:", err) |
| + continue |
| + } |
| + counts, err := s.db.reactionCounts(in.MessageID) |
| + if err != nil { |
| + continue |
| + } |
| + out, _ := json.Marshal(wsReactionOut{Type: "reaction", MessageID: in.MessageID, Reactions: counts}) |
| + s.hub.broadcast <- out |
| } |
| - out, _ := json.Marshal(wsOut{Type: "msg", ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob}) |
| - s.hub.broadcast <- out |
| - // Notify subscribers who don't have the app open (best-effort, async). |
| - go s.pushNewMessage(m.Blob) |
| } |
| } |
diff --git a/main.go b/main.go| index 22f9ef7..8d3869e 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -5,6 +5,7 @@ import ( |
| "encoding/json" |
| "flag" |
| "html" |
| + "io" |
| "io/fs" |
| "log" |
| "net/http" |
| @@ -13,6 +14,9 @@ import ( |
| "strings" |
| ) |
| +// maxUploadBytes caps an encrypted attachment upload (10 MB of ciphertext). |
| +const maxUploadBytes = 10 << 20 |
| + |
| //go:embed web/pages/*.html web/static/* |
| var webFS embed.FS |
| @@ -92,6 +96,11 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("GET /api/messages", s.getMessages) |
| mux.HandleFunc("GET /ws", s.handleWS) |
| + // Encrypted attachments: bytes are E2E-encrypted in the browser; the server |
| + // stores/serves opaque blobs only. Both ends are token-gated. |
| + mux.HandleFunc("POST /api/upload", s.uploadBlob) |
| + mux.HandleFunc("GET /api/blob/{id}", s.getBlobHandler) |
| + |
| // Invite lifecycle (used by the join page) |
| mux.HandleFunc("GET /api/invite/{token}", s.getInvite) |
| mux.HandleFunc("POST /api/invite/{token}/claim", s.claimInvite) |
| @@ -161,9 +170,76 @@ func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + // Attach reactions (with a "mine" flag for this caller's token). |
| + ids := make([]int64, len(msgs)) |
| + for i, m := range msgs { |
| + ids[i] = m.ID |
| + } |
| + if reMap, err := s.db.reactionsForMessages(ids, token); err == nil { |
| + for i := range msgs { |
| + if rs := reMap[msgs[i].ID]; len(rs) > 0 { |
| + msgs[i].Reactions = rs |
| + } |
| + } |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"messages": msgs}) |
| } |
| +// tokenOK reports whether a token is a known, non-revoked invite. |
| +func (s *Server) tokenOK(token string) bool { |
| + exists, revoked, err := s.db.inviteStatus(token) |
| + return err == nil && exists && !revoked |
| +} |
| + |
| +// ---- attachments -------------------------------------------------------- |
| + |
| +func (s *Server) uploadBlob(w http.ResponseWriter, r *http.Request) { |
| + if !s.tokenOK(r.URL.Query().Get("token")) { |
| + httpError(w, http.StatusForbidden, "invalid invite") |
| + return |
| + } |
| + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) |
| + data, err := io.ReadAll(r.Body) |
| + if err != nil { |
| + httpError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MB)") |
| + return |
| + } |
| + if len(data) == 0 { |
| + httpError(w, http.StatusBadRequest, "empty upload") |
| + return |
| + } |
| + id, err := randToken() |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "id error") |
| + return |
| + } |
| + if err := s.db.putBlob(id, data); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"id": id}) |
| +} |
| + |
| +func (s *Server) getBlobHandler(w http.ResponseWriter, r *http.Request) { |
| + if !s.tokenOK(r.URL.Query().Get("token")) { |
| + httpError(w, http.StatusForbidden, "invalid invite") |
| + return |
| + } |
| + data, err := s.db.getBlob(r.PathValue("id")) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + if data == nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/octet-stream") |
| + // Blob ids are unique and content is immutable, so it's safe to cache hard. |
| + w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") |
| + w.Write(data) |
| +} |
| + |
| // ---- invite (join flow) ------------------------------------------------- |
| func (s *Server) getInvite(w http.ResponseWriter, r *http.Request) { |