oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package calls
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "net/http"
8 "net/url"
9 "strings"
10
11 "amadan.net/oficina/estilo.git/chrome"
12 "amadan.net/oficina/estilo.git/sky"
13 "amadan.net/oficina/home/connect"
14 rauth "amadan.net/rastrillo/rastrillo/auth"
15 "amadan.net/rastrillo/rastrillo/sessions"
16 "github.com/keymaildev/signin"
17)
18
19const signinPath = "/signin"
20const sentLocation = signinPath + "?sent=1"
21
22// newAuthPlugin wires rastrillo/auth. Authorize is Calls' one departure
23// from Docs (spec §3.2): an address is admitted at the emailed link when
24// it is on the allowlist, or when it holds an unexpired pending-join row
25// whose link is still live. The row was written before the mail went
26// out, so the click is admitted in whichever browser it lands.
27func (a *app) newAuthPlugin(writer *sql.DB) (*rauth.Auth, error) {
28 landing := "/continue"
29 if a.cfg.home != nil {
30 landing = "/auth/local/complete"
31 }
32 return rauth.New(rauth.Config{
33 DB: writer,
34 Origin: a.cfg.Origin,
35 InstanceKey: a.cfg.instanceKey,
36 Mailer: a.signinMail,
37 Subject: chrome.EmailSubject("Calls"),
38 Authorize: a.authorize,
39 // A verified address authorize refuses gets the door, with the
40 // refusal on it, in auth's own 403 (refused).
41 Refused: a.refused,
42 // GET /auth/verify spends nothing: it opens the door with one
43 // Sign in button that POSTs the link back, so a mail scanner's
44 // fetch cannot spend it (renderConfirm).
45 RenderConfirm: a.renderConfirm,
46 // No proof-of-work on the sign-in form. Calls never had one:
47 // gateBegin's syntax check, mayRequestLink's budgets (ruling 12)
48 // and mailQueue's admission check are what stand between a
49 // stranger and the mailer, and the door is estilo's form, which
50 // renders no pow challenge. ProofOff keeps today's behaviour;
51 // wiring a pow.Guard is a decision of its own (schema, assets, a
52 // form that needs JavaScript).
53 ProofOff: true,
54 SigninPath: signinPath,
55 SignedInPath: landing,
56 Logger: a.logger,
57 })
58}
59
60func (a *app) authorize(address string) bool {
61 if a.allowNow().allows(address) {
62 return true
63 }
64 live, err := a.store.pendingLive(context.Background(), address)
65 if err != nil {
66 a.logger.Error("calls: authorize: pending lookup", "err", err)
67 return false
68 }
69 return live
70}
71
72// gateBegin sits in front of rastrillo's Begin, which it reaches for
73// EVERY well-formed address, so whether an address signs in at Keymail
74// or by emailed link depends on its domain alone, never on membership.
75//
76// - A Keymail address (keymail.dev itself, or a domain delegating to
77// Keymail with a `_keymail` TXT record) goes to Keymail, member or
78// not. Begin answers with a 303 to Keymail's authorize URL, which
79// newBegin's continuation turns into a page: Safari applies
80// form-action 'self' to a form's whole redirect chain. Admission is
81// rastrillo's Authorize after Keymail has verified the address (a
82// member, or a guest holding a live pending-join row), and its
83// refusal is the door saying so (refused).
84// - Any other address is answered ?sent=1, and a link is mailed only
85// when mayRequestLink allowed it for this request (ruling 12's
86// budgets included) and authorize admits the address. mailQueue
87// decides that after the response, in a worker, so not even the
88// response time differs (signin_mail.go).
89//
90// Calls' own refusals are byte-identical to success; rastrillo's
91// address and rate outcomes are shown. Membership of a specific address
92// is not a secret Calls keeps at this form (spec §3.2), but the choice
93// of ceremony must still not depend on it: it used to set force=1 for a
94// permitted address only, which kept Keymail out of Calls altogether
95// (Paul, 2026-10-06: "I do want it to use keymail"). force now arrives
96// only from the form rastrillo lands a failed Keymail exchange on
97// (?force=1&err=keymail), and asks for an emailed link instead.
98func (a *app) gateBegin(next http.HandlerFunc) http.HandlerFunc {
99 return func(w http.ResponseWriter, r *http.Request) {
100 _ = r.ParseForm()
101 address := r.FormValue("address")
102 // Syntax first. The gate below answers every refusal as "sent", so
103 // a malformed address used to go straight to "Check your email"
104 // and the field's own error could never show. Whether a string is
105 // an email address says nothing about who is on the team, so this
106 // answer is no oracle. It is keymail's own check, the one Begin
107 // would make.
108 if _, _, err := signin.SplitAddress(strings.TrimSpace(address)); err != nil {
109 http.Redirect(w, r, a.signinAgain(r, "err=address"), http.StatusSeeOther)
110 return
111 }
112 may := a.mayRequestLink(w, r, address)
113 r = r.WithContext(context.WithValue(r.Context(), mayMailKey{}, may))
114 next(&sameAnswer{ResponseWriter: w, app: a}, r)
115 }
116}
117
118// newBegin is POST /signin in both modes: gateBegin under
119// connect.OAuthContinuation, which ends the POST with a page
120// (renderKeymail) when Begin's answer is Keymail's authorize URL.
121func (a *app) newBegin(begin http.HandlerFunc) http.HandlerFunc {
122 return connect.OAuthContinuation(a.cfg.Origin, a.gateBegin(begin), a.renderKeymail)
123}
124
125// renderKeymail is the page between the form and Keymail: the door's
126// card saying where the person is going, with the meta refresh that
127// goes there (home-oauth-head) and a link for when it is blocked.
128func (a *app) renderKeymail(w http.ResponseWriter, r *http.Request, destination string) {
129 p := connect.OAuthPage(destination)
130 notice := chrome.SignInNotice{
131 Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host),
132 Heading: p.Title, Body: "Taking you to Keymail to finish signing in.",
133 Link: &chrome.Link{Href: p.URL, Label: p.ActionLabel},
134 }
135 render(w, "signin", a.page(r, frame{parts: []string{p.Title}, signinPage: true}, map[string]any{"Sent": true, "Notice": notice, "Continue": p}))
136}
137
138// mayMailKey carries gateBegin's mayRequestLink answer to mailQueue,
139// through Begin, on the request context.
140type mayMailKey struct{}
141
142// mayRequestLink decides whether a link may be mailed (ruling 12). A
143// member address always may. Any other address may only when the
144// browser carries a valid pending-join cookie for a live link, within
145// two budgets: five attempts per link and ten across all links per
146// fifteen minutes.
147//
148// It runs on the response path, so its WORK must not depend on who the
149// address is (Astra on #25, round 3, P1: over budget, a member wrote a
150// pending row while an outsider wrote a log line and returned —
151// 0.4–0.65 ms against 77–78 ms). Whoever the address names, a live
152// cookie costs the same: the link lookup, and the pending_joins row,
153// written for an over-budget outsider too. Only the in-memory budget
154// counters, charged for outsiders as before, differ. Nothing is logged
155// here: an over-budget attempt is counted (overBudget) and the hourly
156// sweep reports the count.
157//
158// The row an over-budget outsider gets admits nothing new by mail: the
159// answer this returns still says no, so no link is sent (the budgets are
160// about mail). It does let a Keymail sign-in through that link's guest
161// path, which the budgets never governed.
162func (a *app) mayRequestLink(w http.ResponseWriter, r *http.Request, address string) bool {
163 address = normalizeAddress(address)
164 member := a.allowNow().allows(address)
165 ck, hasCookie := a.readJoinCookie(r)
166 if !hasCookie {
167 return member
168 }
169 link, _, live, err := a.store.liveLinkByHash(r.Context(), ck.TokenHash)
170 if err != nil {
171 a.logger.Error("calls: link for cookie", "err", err)
172 return member
173 }
174 if !live {
175 return member
176 }
177 may := true
178 if !member {
179 now := a.store.now()
180 // The per-link budget is consulted first: a link already over
181 // its own budget must not keep charging the shared pool.
182 if !a.linkBudget.allow(ck.TokenHash, now) || !a.outsiderBudget.allow("all", now) {
183 a.overBudget.Add(1)
184 may = false
185 }
186 }
187 if address == "" {
188 return member
189 }
190 if err := a.store.writePending(r.Context(), address, link); err != nil {
191 a.logger.Error("calls: write pending join", "err", err)
192 return member
193 }
194 return may
195}
196
197// rastrillo's own Begin also same-origin-checks the request and
198// answers a bare 403 on failure — unreachable behind csrf.Protect
199// here, but a second gate worth knowing runs underneath this one.
200//
201// sameAnswer rewrites rastrillo's own refusal redirects to the sent
202// location. It hooks WriteHeader because http.Redirect sets Location
203// and then calls WriteHeader, so the Location is visible in time.
204// Calls' own refusals are byte-identical to success; rastrillo's
205// address and rate outcomes are shown; membership of a specific
206// address is not a secret Calls keeps at this form, per spec §3.2, and
207// the timing of a sent mail is a further channel the response cannot
208// hide.
209type sameAnswer struct {
210 http.ResponseWriter
211 app *app
212}
213
214func (s *sameAnswer) WriteHeader(status int) {
215 loc := s.Header().Get("Location")
216 rewrite := loc != "" && strings.HasPrefix(loc, signinPath+"?err=") &&
217 loc != signinPath+"?err=address" && loc != signinPath+"?err=rate"
218 if rewrite {
219 s.app.logger.Warn("calls: sign-in refused; answering as if sent", "outcome", loc)
220 s.Header().Set("Location", sentLocation)
221 }
222 s.ResponseWriter.WriteHeader(status)
223}
224
225type personCtxKey struct{}
226
227// requirePerson runs behind RequireSession: it maps the session subject
228// (the verified address; sessions.UserID would return 0, false) to a
229// persons row, creating it on first sign-in.
230func (a *app) requirePerson(next http.Handler) http.Handler {
231 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
232 s, ok := sessions.Current(r)
233 if !ok || s.Subject == "" {
234 a.logger.Error("calls: session with no subject reached requirePerson", "path", r.URL.Path)
235 a.ErrorPage(w, r, http.StatusInternalServerError, "")
236 return
237 }
238 p, err := a.personForSession(r.Context(), s)
239 if errors.Is(err, errHomeRemoved) {
240 // A Home identity that is definitely no longer admitted: end
241 // the session and say so, rather than a 404 whose "Sign in"
242 // would pass straight back through the same live session
243 // (the loop Astra found in Docs #65 and Sheets #39).
244 a.endRemovedSession(w, r)
245 return
246 }
247 if errors.Is(err, errHomeIdentity) {
248 a.notFound(w, r)
249 return
250 }
251 if err != nil {
252 a.logger.Error("calls: ensure person", "err", err)
253 a.ErrorPage(w, r, http.StatusInternalServerError, "")
254 return
255 }
256 next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), personCtxKey{}, p)))
257 })
258}
259
260func currentPerson(r *http.Request) (Person, bool) {
261 p, ok := r.Context().Value(personCtxKey{}).(Person)
262 return p, ok
263}
264
265// requireMember is spec §2.5's first row: current member standing.
266func (a *app) requireMember(next http.Handler) http.Handler {
267 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
268 p, ok := currentPerson(r)
269 if !ok || !a.isMember(p) {
270 a.notFound(w, r)
271 return
272 }
273 next.ServeHTTP(w, r)
274 })
275}
276
277// localSignin serves the sign-in form (GET /signin standalone,
278// /signin/local with Home) and the landing spot for every outcome
279// rastrillo redirects back to (?sent=1, ?err=rate|address|expired|
280// removed|1), in the suite's words (spec §9.2).
281//
282// Already signed in, it skips the form: 303 to return_to or the meeting
283// list. A guest is not signed in for this purpose — signing in is exactly
284// what a guest here is asking to do — and a session that no longer
285// resolves to a person has been ended by signin before this runs.
286//
287// return_to is remembered in a sealed cookie (signin_return.go), and a
288// plain /signin forgets one left by an abandoned attempt.
289func (a *app) localSignin(w http.ResponseWriter, r *http.Request) {
290 q := r.URL.Query()
291 ret, hasReturn := safeReturn(q)
292 if v := a.viewerOf(r); v.signedIn() && q.Get("sent") == "" && q.Get("err") == "" && r.URL.Path == signinPath {
293 dest := "/"
294 if hasReturn {
295 dest = ret
296 }
297 w.Header().Set("Cache-Control", "no-store")
298 http.Redirect(w, r, dest, http.StatusSeeOther)
299 return
300 }
301 switch {
302 case hasReturn:
303 a.rememberReturn(w, ret)
304 case len(q) == 0:
305 a.forgetReturn(w)
306 }
307 w.Header().Set("Cache-Control", "no-store")
308 if q.Get("sent") == "1" {
309 // The door's own check-your-email state (estilo DoorCopy). "Use a
310 // different address" goes back to the form keeping the return
311 // (spec §9.2): the remembered page, not a bare /signin, which would
312 // forget it (Astra on Docs #65, finding 2). rastrillo's Begin
313 // redirects to ?sent=1 with no address, so the body cannot echo it.
314 notice := chrome.SignInNotice{Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host)}.Sent(a.signinAgain(r, ""))
315 render(w, "signin", a.page(r, frame{parts: []string{notice.Page()}, signinPage: true}, map[string]any{"Sent": true, "Notice": notice}))
316 return
317 }
318 door := a.door(r)
319 if hasReturn {
320 door.Return = ret
321 }
322 if q.Get("force") != "" {
323 // A failed Keymail exchange lands here as ?force=1&err=keymail.
324 // Posting force back asks for an emailed link instead, so the
325 // retry does not return to the Keymail that just failed.
326 door.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}}
327 }
328 switch q.Get("err") {
329 case "address":
330 // An address error belongs to the field, not a banner.
331 door.AddressError = "That doesn’t look like an email address."
332 case "rate":
333 door.Error = "Too many attempts. Wait a few minutes, then try again."
334 case "expired":
335 door.Error = "That link has expired or was already used. Enter your email to get a new one."
336 case "removed":
337 door.Error = "Your access has been removed, so you’ve been signed out. Ask whoever runs this team if that’s wrong."
338 case "keymail":
339 door.Error = "Your Keymail sign-in couldn’t be completed. Enter your email to get a link instead."
340 case "":
341 default:
342 door.Error = "We couldn’t sign you in. Enter your email to get a new link."
343 }
344 render(w, "signin", a.page(r, frame{parts: []string{door.Page()}, signinPage: true}, map[string]any{"Door": door}))
345}
346
347// door is the sign-in door for this instance: Calls' mark and name on
348// its sky, posting to the form's own address. The door's words
349// (heading, label, help, the "Sign in" submit) are estilo's one copy
350// set; Calls passes its name, mark, sky and destinations.
351func (a *app) door(r *http.Request) chrome.SignIn {
352 return chrome.SignIn{
353 Icon: assets.Path("static/brand/icon.svg"),
354 App: appName,
355 Sky: sky.For(r.Host),
356 Action: signinPath,
357 OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"},
358 }
359}
360
361// refusalText is what the door says to an address Keymail or an emailed
362// link has just verified and authorize refused.
363const refusalText = "That address can’t sign in to Calls. Use a different address, or ask whoever runs this team to add it."
364
365// refused is auth's Refused hook: Keymail or an emailed link verified
366// the address and authorize said no. It answers with the sign-in door,
367// carrying the refusal, in a 403. No session is minted, and auth has
368// already ended the attempt.
369//
370// Rendered, not redirected to ?err=refused: a query parameter is
371// something anybody can type, and the sentence means "you proved this
372// address and Calls does not admit it" (Astra on #25, round 3). Only
373// this hook can say it, and auth calls it only after the address is
374// proved, so saying it is no membership oracle. The remembered
375// return_to is neither set nor forgotten here: the form is the way on,
376// and a different address may still be going to the same page.
377func (a *app) refused(w http.ResponseWriter, r *http.Request, _ rauth.Identity) {
378 door := a.door(r)
379 door.Error = refusalText
380 w.Header().Set("Content-Type", "text/html; charset=utf-8")
381 w.Header().Set("Cache-Control", "no-store")
382 w.WriteHeader(http.StatusForbidden)
383 render(w, "signin", a.page(r, frame{parts: []string{door.Page()}, signinPage: true}, map[string]any{"Door": door}))
384}
385
386// renderConfirm is auth's RenderConfirm: the page an emailed link opens
387// on. GET /auth/verify spends nothing, so a mail-security scanner that
388// fetches every link in an inbound message cannot spend this one; the
389// door's single Sign in button POSTs the token back to d.Action, and
390// that POST signs in.
391//
392// It is the door with the address field taken away (the same mark,
393// sky, heading and Sign in label), so the person who clicked the link
394// sees Calls, not rastrillo's plain English fallback. auth has already
395// set no-store, Referrer-Policy: strict-origin (the URL is a live
396// credential) and the framing refusals; nothing here replaces them.
397// d.Token is untrusted query input and reaches the page only through
398// html/template.
399func (a *app) renderConfirm(w http.ResponseWriter, r *http.Request, d rauth.ConfirmPageData) {
400 door := a.door(r)
401 door.Lede = "Select Sign in to finish signing in."
402 render(w, "signin", a.page(r, frame{parts: []string{door.Page()}, signinPage: true},
403 map[string]any{"Confirm": signinConfirm{Door: door, Action: d.Action, Token: d.Token}}))
404}
405
406// signinConfirm is the confirm step's door: the chrome, and the form
407// that posts the emailed token back.
408type signinConfirm struct {
409 Door chrome.SignIn
410 Action, Token string
411}
412
413// signinAgain is the form's own address, keeping whatever page the
414// person was on their way to: /signin/local with Home (a local sign-in
415// the person chose on purpose, which /signin would turn into the Home
416// exchange), /signin otherwise. extra is one more query parameter.
417func (a *app) signinAgain(r *http.Request, extra string) string {
418 path := signinPath
419 if a.home != nil {
420 path = "/signin/local"
421 }
422 q := url.Values{}
423 if ret, ok := a.rememberedReturn(r); ok {
424 q.Set("return_to", ret)
425 }
426 out := path
427 if enc := q.Encode(); enc != "" {
428 out += "?" + enc
429 }
430 if extra != "" {
431 if strings.Contains(out, "?") {
432 out += "&" + extra
433 } else {
434 out += "?" + extra
435 }
436 }
437 return out
438}
439
440// continueSignedIn is SignedInPath. Task 6 adds the pending-join branch
441// in front; this slice's first version routes members to the list and
442// everyone else to /welcome.
443func (a *app) continueSignedIn(w http.ResponseWriter, r *http.Request) {
444 p, ok := currentPerson(r)
445 if !ok {
446 a.ErrorPage(w, r, http.StatusInternalServerError, "")
447 return
448 }
449 if dest, handled := a.continuePending(w, r, p); handled {
450 http.Redirect(w, r, dest, http.StatusSeeOther)
451 return
452 }
453 // Where the person was going when they were asked to sign in
454 // (signin_return.go), spent either way.
455 ret, remembered := a.rememberedReturn(r)
456 a.forgetReturn(w)
457 if remembered {
458 http.Redirect(w, r, ret, http.StatusSeeOther)
459 return
460 }
461 if a.isMember(p) {
462 http.Redirect(w, r, "/", http.StatusSeeOther)
463 return
464 }
465 http.Redirect(w, r, "/welcome", http.StatusSeeOther)
466}
467
468// continuePending is /continue's first branch (spec §3.2): the most
469// recent unexpired pending row for this person, re-checked against its
470// link and meeting in one transaction with the grant, then deleted.
471func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person) (string, bool) {
472 a.clearJoinCookie(w)
473 pend, ok, err := a.store.latestPending(r.Context(), p.Email)
474 if err != nil {
475 a.logger.Error("calls: latest pending", "err", err)
476 return "", false
477 }
478 if !ok {
479 return "", false
480 }
481 dest, err := a.store.consumePending(r.Context(), p, pend, a.isMember(p))
482 if err != nil {
483 a.logger.Error("calls: consume pending", "err", err)
484 return "", false
485 }
486 return dest, dest != ""
487}
488
489func (a *app) welcome(w http.ResponseWriter, r *http.Request) {
490 p, _ := currentPerson(r)
491 render(w, "welcome", a.page(r, titled("You’re signed in"), map[string]any{"Email": p.Email}))
492}
493
494// signedOut serves GET /signed-out standalone: where Sign out lands.
495// With Home configured connect serves it, through renderHome.
496func (a *app) signedOut(w http.ResponseWriter, r *http.Request) {
497 a.renderSignedOut(w, r, signinPath, http.StatusOK)
498}
499
500// renderSignedOut is the signed-out page: the door's own notice
501// (estilo's SignedOut, the same words in every app), whose primary Sign in
502// goes to signin — /signin standalone, connect's action with Home.
503func (a *app) renderSignedOut(w http.ResponseWriter, r *http.Request, signin string, status int) {
504 notice := chrome.SignInNotice{Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host)}.SignedOut(signin)
505 w.Header().Set("Content-Type", "text/html; charset=utf-8")
506 w.Header().Set("Cache-Control", "no-store")
507 w.WriteHeader(status)
508 render(w, "signedout", a.page(r, frame{parts: []string{notice.Page()}, signinPage: true}, map[string]any{"Notice": notice}))
509}
510