oficina / meet Public

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>
Paul Campbell pushed by paul@keymail.dev ea7c80d94e14d6c42d5a302bf53043b52b4bbe96 parent 4f6d7ca
8 files changed, +379 −18
  • internal/meet/app.go +5 −0
  • internal/meet/handlers.go +0 −7
  • internal/meet/handlers_meetings.go +179 −0
  • internal/meet/templates/confirm.html +9 −2
  • internal/meet/templates/index.html +32 −0
  • internal/meet/templates/link_once.html +9 −2
  • internal/meettest/harness_test.go +18 −7
  • internal/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")
+}