meetings: list, create, link once, replace and end
Sealed titles listed and opened through the store only; the link is answered on a page, once; replace and end have their confirm pages; the database and WAL never hold a plaintext title, tested before and after a checkpoint.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
8 files changed,
+379
−18
internal/meet/app.go+5 −0internal/meet/handlers.go+0 −7internal/meet/handlers_meetings.go+179 −0internal/meet/templates/confirm.html+9 −2internal/meet/templates/index.html+32 −0internal/meet/templates/link_once.html+9 −2internal/meettest/harness_test.go+18 −7internal/meettest/meetings_test.go+127 −0
diff --git a/internal/meet/app.go b/internal/meet/app.go| index eefe1b6..22dca09 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -78,6 +78,11 @@ func build(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| r.Group(func(r chi.Router) { |
| r.Use(a.requireMember) |
| r.Get("/", a.index) |
| + r.Post("/meetings", a.createMeeting) |
| + r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink) |
| + r.Post("/meetings/{sqid}/links", a.replaceLink) |
| + r.Get("/meetings/{sqid}/end", a.confirmEnd) |
| + r.Post("/meetings/{sqid}/end", a.endMeeting) |
| }) |
| }) |
diff --git a/internal/meet/handlers.go b/internal/meet/handlers.go| deleted file mode 100644 |
| index e44e7ad..0000000 |
| --- a/internal/meet/handlers.go |
| +++ /dev/null |
| @@ -1,7 +0,0 @@ |
| -package meet |
| - |
| -import "net/http" |
| - |
| -func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| - render(w, "index", nil) |
| -} |
diff --git a/internal/meet/handlers_meetings.go b/internal/meet/handlers_meetings.go| new file mode 100644 |
| index 0000000..f06fdba |
| --- /dev/null |
| +++ b/internal/meet/handlers_meetings.go |
| @@ -0,0 +1,179 @@ |
| +package meet |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "time" |
| + |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +const maxTitleLen = 200 |
| + |
| +// linkURL is built from configuration, never from the Host header. |
| +func (a *app) linkURL(token string) string { |
| + return a.cfg.Origin + "/j/" + token |
| +} |
| + |
| +// parseStart reads the optional start time from the form in the |
| +// instance zone (config.go). Returns a message when it is malformed. |
| +func (a *app) parseStart(form url.Values) (*time.Time, string) { |
| + raw := strings.TrimSpace(form.Get("starts_at")) |
| + if raw == "" { |
| + return nil, "" |
| + } |
| + t, err := time.ParseInLocation("2006-01-02T15:04", raw, a.cfg.zone) |
| + if err != nil { |
| + return nil, "Enter the start as a date and time, or leave it blank to start now." |
| + } |
| + u := t.UTC() |
| + return &u, "" |
| +} |
| + |
| +func (a *app) fmtStart(t *time.Time) string { |
| + if t == nil { |
| + return "" |
| + } |
| + return t.In(a.cfg.zone).Format("Mon 2 Jan 2006, 15:04") + " " + a.cfg.Zone |
| +} |
| + |
| +func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| + a.renderIndex(w, r, "", "") |
| +} |
| + |
| +func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, startErr string) { |
| + views, err := a.store.listMeetings(r.Context()) |
| + if err != nil { |
| + a.logger.Error("meet: list meetings", "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + type row struct { |
| + Sqid, Title, Starts string |
| + Ended, LinkPresent bool |
| + } |
| + var upcoming, ended []row |
| + for _, v := range views { |
| + rw := row{Sqid: v.Sqid, Title: v.Title, Starts: a.fmtStart(v.StartsAt), Ended: v.EndedAt != nil, LinkPresent: v.LinkPresent} |
| + if rw.Ended { |
| + ended = append(ended, rw) |
| + } else { |
| + upcoming = append(upcoming, rw) |
| + } |
| + } |
| + render(w, "index", map[string]any{ |
| + "Upcoming": upcoming, "Ended": ended, "Zone": a.cfg.Zone, |
| + "TitleError": titleErr, "StartError": startErr, |
| + }) |
| +} |
| + |
| +func (a *app) createMeeting(w http.ResponseWriter, r *http.Request) { |
| + _ = r.ParseForm() |
| + title := strings.TrimSpace(r.FormValue("title")) |
| + var titleErr string |
| + switch { |
| + case title == "": |
| + titleErr = "Give the meeting a title." |
| + case len(title) > maxTitleLen: |
| + titleErr = "Keep the title under 200 characters." |
| + } |
| + start, startErr := a.parseStart(r.Form) |
| + if titleErr != "" || startErr != "" { |
| + w.WriteHeader(http.StatusUnprocessableEntity) |
| + a.renderIndex(w, r, titleErr, startErr) |
| + return |
| + } |
| + p, _ := currentPerson(r) |
| + m, token, err := a.store.createMeeting(r.Context(), title, start, actorFor(p)) |
| + if err != nil { |
| + a.logger.Error("meet: create meeting", "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + a.renderLinkOnce(w, m, token, false) |
| +} |
| + |
| +// renderLinkOnce answers with a page, not a redirect: a token in a |
| +// query string lands in the edge's access logs (Sheets' ruling), and |
| +// the page is the one time the token exists in plaintext. |
| +func (a *app) renderLinkOnce(w http.ResponseWriter, m Meeting, token string, replaced bool) { |
| + render(w, "link_once", map[string]any{ |
| + "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt), |
| + "Link": a.linkURL(token), "Replaced": replaced, |
| + }) |
| +} |
| + |
| +// meetingForMember loads the meeting in the URL or answers 404. |
| +func (a *app) meetingForMember(w http.ResponseWriter, r *http.Request) (Meeting, bool) { |
| + m, ok, err := a.store.meetingBySqid(r.Context(), chi.URLParam(r, "sqid")) |
| + if err != nil { |
| + a.logger.Error("meet: load meeting", "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return Meeting{}, false |
| + } |
| + if !ok { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return Meeting{}, false |
| + } |
| + return m, true |
| +} |
| + |
| +func (a *app) confirmReplaceLink(w http.ResponseWriter, r *http.Request) { |
| + m, ok := a.meetingForMember(w, r) |
| + if !ok { |
| + return |
| + } |
| + render(w, "confirm", map[string]any{ |
| + "Title": "Replace the link?", |
| + "Body": "Everyone holding the current link for \"" + a.store.title(m) + "\" will get a not-found page. You will see the new link once.", |
| + "Action": "/meetings/" + m.Sqid + "/links", "Label": "Replace the link", |
| + "CancelHref": "/", "Sqid": m.Sqid, |
| + }) |
| +} |
| + |
| +func (a *app) replaceLink(w http.ResponseWriter, r *http.Request) { |
| + m, ok := a.meetingForMember(w, r) |
| + if !ok { |
| + return |
| + } |
| + if m.EndedAt != nil { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + p, _ := currentPerson(r) |
| + token, err := a.store.replaceLink(r.Context(), m.ID, actorFor(p)) |
| + if err != nil { |
| + a.logger.Error("meet: replace link", "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + a.renderLinkOnce(w, m, token, true) |
| +} |
| + |
| +func (a *app) confirmEnd(w http.ResponseWriter, r *http.Request) { |
| + m, ok := a.meetingForMember(w, r) |
| + if !ok { |
| + return |
| + } |
| + render(w, "confirm", map[string]any{ |
| + "Title": "End the meeting?", |
| + "Body": "\"" + a.store.title(m) + "\" will close for everyone and its link will stop working.", |
| + "Action": "/meetings/" + m.Sqid + "/end", "Label": "End the meeting", |
| + "CancelHref": "/", "Sqid": m.Sqid, |
| + }) |
| +} |
| + |
| +func (a *app) endMeeting(w http.ResponseWriter, r *http.Request) { |
| + m, ok := a.meetingForMember(w, r) |
| + if !ok { |
| + return |
| + } |
| + p, _ := currentPerson(r) |
| + if err := a.store.endMeeting(r.Context(), m.ID, actorFor(p)); err != nil { |
| + a.logger.Error("meet: end meeting", "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + http.Redirect(w, r, "/", http.StatusSeeOther) |
| +} |
diff --git a/internal/meet/templates/confirm.html b/internal/meet/templates/confirm.html| index 8326cd1..7b7652c 100644 |
| --- a/internal/meet/templates/confirm.html |
| +++ b/internal/meet/templates/confirm.html |
| @@ -1,2 +1,9 @@ |
| -{{define "title"}}Meet{{end}} |
| -{{define "content"}}{{end}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{template "back-nav" dict "Href" .CancelHref "Label" "Meetings"}} |
| +{{template "page-header" dict "Title" .Title}} |
| +<div rst-box> |
| + <p>{{.Body}}</p> |
| + {{template "confirm-form" dict "Action" .Action "Label" .Label "Danger" true "CancelHref" .CancelHref "CancelLabel" "Cancel"}} |
| +</div> |
| +{{end}} |
diff --git a/internal/meet/templates/index.html b/internal/meet/templates/index.html| index 90975f3..755d96f 100644 |
| --- a/internal/meet/templates/index.html |
| +++ b/internal/meet/templates/index.html |
| @@ -1,4 +1,36 @@ |
| {{define "title"}}Meetings{{end}} |
| {{define "content"}} |
| {{template "page-header" dict "Title" "Meetings"}} |
| +<section> |
| +<h2>New meeting</h2> |
| +<form rst-form method="post" action="/meetings"> |
| + {{template "field" dict "ID" "title" "Name" "title" "Label" "Title" "Required" true "Maxlength" "200" "Error" .TitleError "Autofocus" true}} |
| + {{template "field" dict "ID" "starts_at" "Name" "starts_at" "Label" (printf "Starts (%s), or leave blank to start now" .Zone) "Type" "datetime-local" "Error" .StartError}} |
| + <div rst-form-actions><button type="submit" rst-btn="primary lg">Create meeting</button></div> |
| +</form> |
| +</section> |
| +<section> |
| +<h2>Upcoming</h2> |
| +{{if .Upcoming}} |
| +<div rst-list> |
| +{{range .Upcoming}} |
| +<article> |
| + <h3><a href="/m/{{.Sqid}}/join">{{.Title}}</a></h3> |
| + <p>{{if .Starts}}Starts {{.Starts}}{{else}}Starts now{{end}}{{if not .LinkPresent}} · no live link{{end}}</p> |
| + <p><a href="/meetings/{{.Sqid}}/links/replace">Replace the link</a> · <a href="/meetings/{{.Sqid}}/end">End</a></p> |
| +</article> |
| +{{end}} |
| +</div> |
| +{{else}} |
| +{{template "empty-state" dict "Body" "No meetings yet. Make one above and share its link."}} |
| +{{end}} |
| +</section> |
| +{{if .Ended}} |
| +<details> |
| +<summary>Ended meetings ({{len .Ended}})</summary> |
| +<div rst-list> |
| +{{range .Ended}}<article><h3>{{.Title}}</h3><p>Ended</p></article>{{end}} |
| +</div> |
| +</details> |
| +{{end}} |
| {{end}} |
diff --git a/internal/meet/templates/link_once.html b/internal/meet/templates/link_once.html| index 8326cd1..bfee90e 100644 |
| --- a/internal/meet/templates/link_once.html |
| +++ b/internal/meet/templates/link_once.html |
| @@ -1,2 +1,9 @@ |
| -{{define "title"}}Meet{{end}} |
| -{{define "content"}}{{end}} |
| +{{define "title"}}Your meeting link{{end}} |
| +{{define "content"}} |
| +{{template "back-nav" dict "Href" "/" "Label" "Meetings"}} |
| +{{template "page-header" dict "Title" .Title}} |
| +{{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}} |
| +<p>{{if .Replaced}}The old link has stopped working. {{end}}This is the only time this link is shown. Copy it now and send it to the people you are meeting.</p> |
| +<p><input rst-input readonly value="{{.Link}}" size="80" aria-label="Meeting link"></p> |
| +<p><a href="/m/{{.Sqid}}/join" rst-btn="primary">Go to the meeting</a></p> |
| +{{end}} |
diff --git a/internal/meettest/harness_test.go b/internal/meettest/harness_test.go| index 697ddb7..5f80966 100644 |
| --- a/internal/meettest/harness_test.go |
| +++ b/internal/meettest/harness_test.go |
| @@ -8,6 +8,7 @@ package meettest |
| import ( |
| "context" |
| + "database/sql" |
| "io" |
| "log/slog" |
| "net/http" |
| @@ -33,13 +34,16 @@ const testOrigin = "http://app.test" |
| // clock and mailer hooks tests need to drive time and read magic |
| // links without a real mail server. |
| type harness struct { |
| - h http.Handler |
| - db *db.DB |
| - mail *recordingMailer |
| - clock func(func() time.Time) |
| - now time.Time |
| + h http.Handler |
| + db *db.DB |
| + dbPath string |
| + mail *recordingMailer |
| + clock func(func() time.Time) |
| + now time.Time |
| } |
| +func (x *harness) sqlDB() *sql.DB { s, _ := x.db.G.DB(); return s } |
| + |
| func (x *harness) advance(d time.Duration) { |
| x.now = x.now.Add(d) |
| now := x.now |
| @@ -80,7 +84,8 @@ func (m *recordingMailer) count() int { |
| func newHarness(t *testing.T, members ...string) *harness { |
| t.Helper() |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| - d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| + dbPath := filepath.Join(t.TempDir(), "app.db") |
| + d, err := db.Open(dbPath, logger) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -92,7 +97,7 @@ func newHarness(t *testing.T, members ...string) *harness { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - x := &harness{h: mux, db: d, mail: rec, clock: setClock, now: time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)} |
| + x := &harness{h: mux, db: d, dbPath: dbPath, mail: rec, clock: setClock, now: time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)} |
| x.advance(0) |
| return x |
| } |
| @@ -160,6 +165,12 @@ func (c *client) post(target string, form url.Values) *httptest.ResponseRecorder |
| return c.do(req) |
| } |
| +func httptestPost(target string, form url.Values) *http.Request { |
| + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + return req |
| +} |
| + |
| type nopMailer struct{} |
| func (nopMailer) Send(ctx context.Context, to, subject, body string) error { return nil } |
diff --git a/internal/meettest/meetings_test.go b/internal/meettest/meetings_test.go| new file mode 100644 |
| index 0000000..b783cb1 |
| --- /dev/null |
| +++ b/internal/meettest/meetings_test.go |
| @@ -0,0 +1,127 @@ |
| +package meettest |
| + |
| +import ( |
| + "bytes" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +var linkInPage = regexp.MustCompile(`http://app\.test/j/([A-Za-z0-9_-]{43})`) |
| + |
| +func createMeeting(t *testing.T, c *client, title, starts string) (sqid, token string) { |
| + t.Helper() |
| + rec := c.post("/meetings", url.Values{"title": {title}, "starts_at": {starts}}) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("POST /meetings: %d\n%s", rec.Code, rec.Body.String()) |
| + } |
| + m := linkInPage.FindStringSubmatch(rec.Body.String()) |
| + if m == nil { |
| + t.Fatalf("no link on the page:\n%s", rec.Body.String()) |
| + } |
| + s := regexp.MustCompile(`/m/([23456789a-z]{10})/join`).FindStringSubmatch(rec.Body.String()) |
| + return s[1], m[1] |
| +} |
| + |
| +func TestCreateShowsTheLinkOnceAndTheListNever(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + _, token := createMeeting(t, c, "Board meeting", "") |
| + rec := c.get("/") |
| + if strings.Contains(rec.Body.String(), token) { |
| + t.Fatal("the list shows a link token") |
| + } |
| + if !strings.Contains(rec.Body.String(), "Board meeting") { |
| + t.Fatal("the list does not show the title") |
| + } |
| +} |
| + |
| +func TestCreateRefusesAnEmptyTitleInPlace(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + rec := c.post("/meetings", url.Values{"title": {" "}}) |
| + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Give the meeting a title") { |
| + t.Fatalf("empty title: %d", rec.Code) |
| + } |
| +} |
| + |
| +func TestReplaceLinkNeedsItsConfirmPageAndKillsTheOldLink(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + sqid, old := createMeeting(t, c, "x", "") |
| + if rec := c.get("/meetings/" + sqid + "/links/replace"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Replace the link") { |
| + t.Fatalf("confirm page: %d", rec.Code) |
| + } |
| + rec := c.post("/meetings/"+sqid+"/links", nil) |
| + if rec.Code != http.StatusOK || !linkInPage.MatchString(rec.Body.String()) { |
| + t.Fatalf("replace: %d", rec.Code) |
| + } |
| + if rec := x.browser(t).get("/j/" + old); rec.Code != http.StatusNotFound { |
| + t.Fatalf("old link after replace: %d, want 404", rec.Code) |
| + } |
| +} |
| + |
| +func TestEndMeetingNeedsItsConfirmPageAndRefusesJoinsAfter(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + sqid, tok := createMeeting(t, c, "x", "") |
| + if rec := c.get("/meetings/" + sqid + "/end"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "End the meeting") { |
| + t.Fatalf("confirm page: %d", rec.Code) |
| + } |
| + if rec := c.post("/meetings/"+sqid+"/end", nil); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("end: %d", rec.Code) |
| + } |
| + if rec := x.browser(t).get("/j/" + tok); rec.Code != http.StatusNotFound { |
| + t.Fatalf("link after end: %d, want 404", rec.Code) |
| + } |
| + if rec := c.get("/"); !strings.Contains(rec.Body.String(), "Ended meetings (1)") { |
| + t.Fatal("ended meeting not in the collapsed section") |
| + } |
| +} |
| + |
| +func TestCrossOriginPostIsRefused(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + sqid, _ := createMeeting(t, c, "x", "") |
| + req := httptestPost("/meetings/"+sqid+"/end", nil) |
| + req.Header.Set("Origin", "https://evil.example") |
| + rec := c.do(req) |
| + if rec.Code != http.StatusForbidden { |
| + t.Fatalf("cross-origin end: %d, want 403", rec.Code) |
| + } |
| +} |
| + |
| +// The one rule (spec §0.1): the database file and its WAL never hold a |
| +// plaintext title. |
| +func TestTheDatabaseNeverHoldsAPlaintextTitle(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + const secret = "Quarterly board meeting about the acquisition" |
| + createMeeting(t, c, secret, "") |
| + grep := func(when string) { |
| + for _, suffix := range []string{"", "-wal", "-shm"} { |
| + b, err := os.ReadFile(x.dbPath + suffix) |
| + if err != nil { |
| + continue |
| + } |
| + if bytes.Contains(b, []byte(secret)) { |
| + t.Errorf("%s: title in plaintext in %s", when, filepath.Base(x.dbPath+suffix)) |
| + } |
| + } |
| + } |
| + grep("before checkpoint") |
| + if _, err := x.sqlDB().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { |
| + t.Fatal(err) |
| + } |
| + grep("after checkpoint") |
| +} |