rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1package idear
2
3import (
4 "context"
5 "errors"
6 "log/slog"
7 "net/http"
8 "strconv"
9 "strings"
10
11 "github.com/carlosframework/rastrillo/flash"
12)
13
14// The copy every refusal renders. Fixed strings, never the error's own
15// text: a store error can carry a subject, an address or a driver
16// message, and a page that echoed it would publish them to whoever
17// provoked it. The log line gets the detail; the page gets the class.
18const (
19 invalidCopy = "That request was not valid."
20 forbiddenCopy = "You may not do that."
21 lastOwnerCopy = "The owner cannot be removed. Transfer ownership first."
22 noInvitationCopy = "That invitation is no longer available."
23 failedCopy = "Something went wrong. Please try again."
24 signInFirstCopy = "Sign in first, then open this invitation link again."
25 rateLimitedCopy = "Too many requests. Please wait a moment and try again."
26)
27
28// The notices a successful mutation flashes. They are one-shot display
29// state (rastrillo/flash), read back by Members on the redirect that
30// follows.
31const (
32 invitedNotice = "Invitation created."
33 deliveredNotice = "Invitation sent."
34 undeliveredNotice = "The invitation was created but could not be sent. Revoke it and try again."
35 revokedNotice = "Invitation revoked."
36 roleNotice = "Role updated."
37 removedNotice = "Member removed."
38 restoredNotice = "Member restored."
39 transferNotice = "Ownership transferred."
40 joinedNotice = "Welcome — your membership is set up."
41 alreadyNotice = "You are already a member here."
42)
43
44// flashError is the flash Kind that lands in MembersPage.Error rather
45// than MembersPage.Notice. Anything else is a notice.
46const flashError = "error"
47
48// MembersPage is what RenderMembers receives.
49//
50// Grantable is the roles the VIEWER may hand out, and a role selector
51// must be built from it rather than from the three constants. An Admin
52// may grant only Member — checkInviteRole refuses a grant that is not
53// STRICTLY BELOW the actor's own rank — so a form that offered Admin
54// to an Admin would 403 on every submit, and one that offered Owner
55// would 403 for everybody including the Owner. It is computed by
56// Grantable, which asks the same predicate the store enforces, so the
57// selector cannot drift from the rule.
58type MembersPage struct {
59 Viewer *Member
60 Members []Member
61 Invitations []Invitation
62 Grantable []Role
63 Error string
64 Notice string
65}
66
67// InvitationPage is what RenderInvitation receives, on the public
68// invitation routes.
69//
70// THERE IS NO ADDRESS FIELD, and that is the design rather than an
71// omission. GET /invitations/{token} is an unauthenticated lookup of a
72// secret: it names the instance and the role so the holder knows what
73// they are accepting, and it must not echo the invited address, which
74// would turn a leaked or guessed link into a disclosure. A renderer
75// cannot print what it is never handed.
76//
77// SignedIn says the viewer has a session; Reconcile says they have one
78// AND no member row — the orphan POST is for. A template shows the
79// "accept" button to a Reconcile viewer, and a "sign up with this
80// link" form to everybody else.
81type InvitationPage struct {
82 Role Role
83 Site string
84 Token string
85 Error string
86 SignedIn bool
87 Reconcile bool
88}
89
90// HandlerConfig wires the flows to the app's own shell. Roster and
91// both renderers are required; everything else has a serviceable
92// default.
93//
94// There is deliberately NO NotFound here, though the design sketch
95// shows one: the 404 renderer lives on idear.Config, because Require
96// is a method on the Roster and answers non-members long before a
97// handler runs. Two hooks would mean two 404 pages, and a 404 that
98// varies with which layer refused is the membership oracle the whole
99// design is arranged to avoid. Set Config.NotFound to the same
100// renderer the app gives chi's own NotFound and every refusal in idear
101// renders through it.
102type HandlerConfig struct {
103 Roster *Roster
104 RenderMembers func(w http.ResponseWriter, r *http.Request, d MembersPage)
105 RenderInvitation func(w http.ResponseWriter, r *http.Request, d InvitationPage)
106
107 // Site names this instance on the invitation page — "Acme's
108 // board".
109 //
110 // SET IT IN PRODUCTION. The default is the request's Host, and
111 // Host is a CLIENT-SUPPLIED HEADER: on the public, unauthenticated
112 // invitation page, an attacker who mails somebody a link and can
113 // steer the Host (a permissive reverse proxy, a wildcard vhost,
114 // a raw request) has the page render text of their choosing as
115 // the instance's name — "Acme Security, verify your password at
116 // ...". idear escapes nothing on the app's behalf either; the
117 // renderer owns that. The default is honest about where the
118 // request landed, which is useful in development and is not a
119 // name you should show a stranger.
120 Site string
121
122 // MembersPath is where a successful mutation redirects. Default
123 // "/members". An app that mounts the members page elsewhere must
124 // say so here, or every 303 lands on a 404.
125 MembersPath string
126
127 // InvitationPath is the prefix a redeemable link is built from:
128 // InvitationPath + token. Default "/invitations/".
129 InvitationPath string
130
131 // Deliver mails the invitation link. It is given the request (for
132 // an origin, a locale, a logger), the invitation and the link.
133 //
134 // When it is nil idear puts the LINK ITSELF in the flash notice,
135 // so a bare mount is usable: the token is shown exactly once, to
136 // the admin who minted it, and can then be pasted into whatever
137 // channel the team uses.
138 //
139 // That fallback puts a live credential in a cookie, and
140 // rastrillo/flash sets HttpOnly, SameSite=Lax and MaxAge=60 but
141 // NOT Secure — so on a plain-http origin the token crosses the
142 // wire in clear text, and it is written by a package idear does
143 // not own, so idear cannot add the flag. An app that can send
144 // mail should set this and keep the token out of the browser
145 // entirely; NewHandlers logs a warning when it is nil.
146 Deliver func(r *http.Request, inv *Invitation, link string) error
147
148 // RateLimit bounds the two public routes. See RateLimit — the
149 // zero value is the documented default, and there is no way to
150 // turn the limiter off.
151 RateLimit RateLimit
152
153 // ClientKey is the rate-limit key for a request. Default: the IP
154 // half of RemoteAddr. Behind a reverse proxy, set this to read
155 // the app's own TRUSTED forwarding header — see clientIP for why
156 // idear will not guess at one.
157 ClientKey func(r *http.Request) string
158}
159
160// Handlers is idear's HTTP surface: the members page, the six
161// management mutations, and the two public invitation routes. Build
162// one at boot and mount Routes.
163type Handlers struct {
164 cfg HandlerConfig
165 limit *limiter
166}
167
168// NewHandlers validates cfg and returns the handlers. It errors unless
169// the Roster and BOTH renderers are set, following jobs.NewHandlers:
170// a nil renderer is a nil call in a request, and a boot error is a
171// better place to learn about it than a production panic.
172func NewHandlers(cfg HandlerConfig) (*Handlers, error) {
173 if cfg.Roster == nil {
174 return nil, errors.New("idear: HandlerConfig.Roster is required")
175 }
176 if cfg.RenderMembers == nil {
177 return nil, errors.New("idear: HandlerConfig.RenderMembers is required")
178 }
179 if cfg.RenderInvitation == nil {
180 return nil, errors.New("idear: HandlerConfig.RenderInvitation is required")
181 }
182 if cfg.MembersPath == "" {
183 cfg.MembersPath = "/members"
184 }
185 if cfg.InvitationPath == "" {
186 cfg.InvitationPath = "/invitations/"
187 }
188 if cfg.ClientKey == nil {
189 cfg.ClientKey = clientIP
190 }
191 if cfg.Deliver == nil {
192 // Once, at boot, rather than on every invitation: the
193 // fallback is usable and it is not what a deployed app should
194 // be doing. See HandlerConfig.Deliver.
195 cfg.Roster.cfg.Logger.Warn("idear: no HandlerConfig.Deliver, so invitation LINKS will be flashed to the inviting admin's browser in a cookie that rastrillo/flash does not mark Secure; set Deliver to mail them instead")
196 }
197 return &Handlers{cfg: cfg, limit: newLimiter(cfg.RateLimit)}, nil
198}
199
200// Route is one mounted handler: the method and pattern idear suggests,
201// the handler ALREADY WRAPPED in the middleware it requires, and the
202// rank that wrapping enforces.
203//
204// The handler is pre-guarded on purpose. Require and RequireRole have
205// a stacking order that is wrong in two different ways when it is got
206// wrong (RequireRole mounted bare 403s a stranger, which tells them
207// the route exists; Require mounted outside the app's session guard
208// 404s everyone including the Owner), so Routes hands out the correct
209// composition rather than a bare handler and a comment. What an app
210// still owns is the paths and the session guard these are mounted
211// inside.
212//
213// Min is the rank floor the handler enforces, and Public says the
214// route is unauthenticated. They are not decoration: they let a
215// caller — an app's route audit, or idear's own authorization suite —
216// DERIVE what each route should refuse instead of restating a list
217// that can drift from the one being mounted.
218type Route struct {
219 Method string
220 Pattern string
221 Handler http.Handler
222 Min Role
223 Public bool
224}
225
226// Routes is the whole HTTP surface, in the order the design lists it.
227//
228// The patterns are defaults; paths belong to the app. An app that
229// mounts them elsewhere must set MembersPath and InvitationPath to
230// match, since those are what the redirects and the invitation links
231// are built from.
232func (h *Handlers) Routes() []Route {
233 rs := h.cfg.Roster
234 guard := func(min Role, fn http.HandlerFunc) http.Handler {
235 return rs.Require(rs.RequireRole(min)(fn))
236 }
237 return []Route{
238 {http.MethodGet, "/members", guard(RoleMember, h.Members), RoleMember, false},
239 {http.MethodPost, "/members/invitations", guard(RoleAdmin, h.Invite), RoleAdmin, false},
240 {http.MethodPost, "/members/invitations/{id}/revoke", guard(RoleAdmin, h.Revoke), RoleAdmin, false},
241 {http.MethodPost, "/members/{id}/role", guard(RoleAdmin, h.SetRole), RoleAdmin, false},
242 {http.MethodPost, "/members/{id}/remove", guard(RoleAdmin, h.Remove), RoleAdmin, false},
243 {http.MethodPost, "/members/{id}/restore", guard(RoleAdmin, h.Restore), RoleAdmin, false},
244 {http.MethodPost, "/members/transfer", guard(RoleOwner, h.Transfer), RoleOwner, false},
245 {http.MethodGet, "/invitations/{token}", http.HandlerFunc(h.Invitation), "", true},
246 {http.MethodPost, "/invitations/{token}", http.HandlerFunc(h.Accept), "", true},
247 }
248}
249
250// Grantable is the roles actor may hand out, highest first — what a
251// role selector must be built from.
252//
253// It is derived from the store's OWN predicates and never from a
254// second copy of the rule: mayManage is the authority floor Invite and
255// SetRole check first (active, and at least Admin), and
256// checkInviteRole is the rank rule they check next, both inside their
257// transactions. So the form and the store cannot disagree, and a
258// deactivated actor — whose row outlives their privileges, because
259// removal is never a delete — is offered nothing at all. RoleOwner is
260// never in it for anybody: ownership moves only by Transfer.
261func Grantable(actor *Member) []Role {
262 if actor == nil || mayManage(actor) != nil {
263 return nil
264 }
265 var out []Role
266 for _, role := range []Role{RoleOwner, RoleAdmin, RoleMember} {
267 if checkInviteRole(actor, role) == nil {
268 out = append(out, role)
269 }
270 }
271 return out
272}
273
274// Members is GET /members: the roster, the pending invitations, and
275// whatever the last mutation flashed.
276func (h *Handlers) Members(w http.ResponseWriter, r *http.Request) {
277 viewer, ok := h.viewer(w, r)
278 if !ok {
279 return
280 }
281 d := h.membersPage(r.Context(), viewer)
282 if f, ok := flash.Take(w, r); ok {
283 if f.Kind == flashError {
284 d.Error = f.Message
285 } else {
286 d.Notice = f.Message
287 }
288 }
289 h.cfg.RenderMembers(w, r, d)
290}
291
292// Invite is POST /members/invitations.
293//
294// The role IS read from the form here, and that is safe for exactly
295// one reason: checkInviteRole refuses RoleOwner outright, for every
296// actor including the Owner, and refuses anything not strictly below
297// the actor's own rank. A posted role can therefore only ever be worth
298// LESS than the poster already holds. Every other field is read by
299// name too — there is no struct binding anywhere in this file, so a
300// field nobody named cannot arrive.
301func (h *Handlers) Invite(w http.ResponseWriter, r *http.Request) {
302 viewer, ok := h.viewer(w, r)
303 if !ok {
304 return
305 }
306 role, valid := ParseRole(field(r, "role"))
307 if !valid {
308 h.refuse(w, r, viewer, ErrInvalidRole)
309 return
310 }
311 inv, token, err := h.cfg.Roster.Invite(r.Context(), viewer, field(r, "email"), role)
312 if err != nil {
313 h.refuse(w, r, viewer, err)
314 return
315 }
316
317 link := h.cfg.InvitationPath + token
318 if h.cfg.Deliver == nil {
319 // No delivery hook: the link is the notice. See
320 // HandlerConfig.Deliver.
321 h.done(w, r, invitedNotice+" "+link)
322 return
323 }
324 if err := h.cfg.Deliver(r, inv, link); err != nil {
325 h.log().Error("idear: an invitation was created but could not be delivered",
326 "invitation_id", inv.ID, "err", err)
327 h.flash(w, r, flashError, undeliveredNotice)
328 return
329 }
330 h.done(w, r, deliveredNotice)
331}
332
333// Revoke is POST /members/invitations/{id}/revoke. The id comes from
334// the URL, never from the body.
335func (h *Handlers) Revoke(w http.ResponseWriter, r *http.Request) {
336 viewer, ok := h.viewer(w, r)
337 if !ok {
338 return
339 }
340 id, err := pathID(r)
341 if err != nil {
342 h.refuse(w, r, viewer, err)
343 return
344 }
345 if err := h.cfg.Roster.Revoke(r.Context(), viewer, id); err != nil {
346 h.refuse(w, r, viewer, err)
347 return
348 }
349 h.done(w, r, revokedNotice)
350}
351
352// SetRole is POST /members/{id}/role — the target from the URL, the
353// new role from the form. See Invite for why a posted role is safe
354// here and cannot reach Owner.
355func (h *Handlers) SetRole(w http.ResponseWriter, r *http.Request) {
356 viewer, target, ok := h.target(w, r)
357 if !ok {
358 return
359 }
360 role, valid := ParseRole(field(r, "role"))
361 if !valid {
362 h.refuse(w, r, viewer, ErrInvalidRole)
363 return
364 }
365 if err := h.cfg.Roster.SetRole(r.Context(), viewer, target, role); err != nil {
366 h.refuse(w, r, viewer, err)
367 return
368 }
369 h.done(w, r, roleNotice)
370}
371
372// Remove is POST /members/{id}/remove: deactivation, never a delete.
373func (h *Handlers) Remove(w http.ResponseWriter, r *http.Request) {
374 viewer, target, ok := h.target(w, r)
375 if !ok {
376 return
377 }
378 if err := h.cfg.Roster.Deactivate(r.Context(), viewer, target); err != nil {
379 h.refuse(w, r, viewer, err)
380 return
381 }
382 h.done(w, r, removedNotice)
383}
384
385// Restore is POST /members/{id}/restore: readmission at the role the
386// row still carries. It is the only way back in for someone who was
387// removed — see Roster.Reactivate.
388func (h *Handlers) Restore(w http.ResponseWriter, r *http.Request) {
389 viewer, target, ok := h.target(w, r)
390 if !ok {
391 return
392 }
393 if err := h.cfg.Roster.Reactivate(r.Context(), viewer, target); err != nil {
394 h.refuse(w, r, viewer, err)
395 return
396 }
397 h.done(w, r, restoredNotice)
398}
399
400// Transfer is POST /members/transfer, the one mutation whose target id
401// comes from the FORM and not the URL: the path has no id in it,
402// because the actor is always the current Owner and the route is not
403// addressed by them.
404//
405// No role is read here at all. Transfer is the only path to RoleOwner,
406// and it decides both roles itself.
407func (h *Handlers) Transfer(w http.ResponseWriter, r *http.Request) {
408 viewer, ok := h.viewer(w, r)
409 if !ok {
410 return
411 }
412 id, err := parseID(field(r, "member"))
413 if err != nil {
414 h.refuse(w, r, viewer, err)
415 return
416 }
417 target, err := h.cfg.Roster.ByID(r.Context(), id)
418 if err != nil {
419 h.refuse(w, r, viewer, err)
420 return
421 }
422 if err := h.cfg.Roster.Transfer(r.Context(), viewer, target); err != nil {
423 h.refuse(w, r, viewer, err)
424 return
425 }
426 h.done(w, r, transferNotice)
427}
428
429// Invitation is GET /invitations/{token}: PUBLIC, unauthenticated, and
430// a lookup of a secret.
431//
432// It renders the ROLE and the INSTANCE, and never the invited address:
433// InvitationPage has no field for one. Every unusable token — no such
434// token, spent, revoked, expired — renders the SAME copy at the same
435// status, because ErrNoInvitation is deliberately one error and not
436// four, and a page that told them apart would answer questions the
437// holder is not entitled to ask.
438//
439// It is rate-limited. See RateLimit.
440func (h *Handlers) Invitation(w http.ResponseWriter, r *http.Request) {
441 if !h.allow(w, r) {
442 return
443 }
444 token := r.PathValue("token")
445 signedIn, reconcile := h.standing(r)
446
447 inv, err := h.cfg.Roster.pendingInvitation(r.Context(), token)
448 if err != nil {
449 h.deadInvitation(w, r, err, signedIn)
450 return
451 }
452 h.cfg.RenderInvitation(w, r, InvitationPage{
453 Role: inv.Role,
454 Site: h.site(r),
455 Token: token,
456 SignedIn: signedIn,
457 Reconcile: reconcile,
458 })
459}
460
461// Accept is POST /invitations/{token}: the RECONCILIATION route, and
462// the only path by which a member row is created from an already-live
463// session.
464//
465// It exists because admission cannot be one transaction. Roster.
466// Admitting calls the app's opaque Create and then writes the member;
467// a failure between them leaves a user row with no membership, and a
468// retry does NOT heal it — the retry's Create fails on the now
469// duplicate email and password.Signup renders that as 422 without ever
470// reaching the member write. The orphan can sign IN and 404s on every
471// guarded route forever. Two concurrent first signups reach the same
472// place with no failure at all: one wins the Claim and the
473// ErrOwnerExists loser is an orphan.
474//
475// What it does, in order, for a viewer who is SIGNED IN:
476//
477// 1. already a member: nothing to reconcile — a notice and a
478// redirect, and the token is NOT spent.
479// 2. a DEACTIVATED member: refused. Readmission is Restore, by an
480// admin; an invitation must not be a way for a removed person to
481// let themselves back in.
482// 3. the roster has ZERO ROWS: Claim, exactly as admission would
483// have. An unclaimed instance's first account is its Owner, and
484// the only way to be signed in against an empty roster is to be
485// the orphan whose Claim did not commit.
486// 4. otherwise: the token must be REDEEMABLE BY THIS VIEWER —
487// issued to their address, wherever idear can learn it (keymail's
488// Subject is the address; under password, Config.EmailForSubject
489// resolves one) — and then Accept it, through the CAS in the
490// store, so a Revoke racing this never admits. Under password
491// with no resolver there is no address to match and the token
492// alone decides; that gap is Config.EmailForSubject's whole
493// subject matter.
494//
495// The member row is written by the STORE, from the live session
496// Subject, and never assembled here: Subject is canonicalised on every
497// write (normalizeSubject), and under keymail the raw subject is the
498// address as the visitor typed it. A Member literal built in this
499// handler would reintroduce exactly the bug that canonicalisation
500// exists to close.
501//
502// It needs a VALID TOKEN. A claim-race loser holds none, so they are
503// healed only after somebody invites them — at which point they redeem
504// here rather than through sign-up, which would fail on the duplicate
505// email. That is the design spec's corrected wording and it is the
506// reason this route is not a general "make me a member" button.
507//
508// It is rate-limited. See RateLimit.
509func (h *Handlers) Accept(w http.ResponseWriter, r *http.Request) {
510 if !h.allow(w, r) {
511 return
512 }
513 token := r.PathValue("token")
514 rs := h.cfg.Roster
515 ctx := r.Context()
516
517 subject, ok := rs.cfg.Subject(r)
518 subject = strings.TrimSpace(subject)
519 if !ok || subject == "" {
520 // Not signed in. Reconciliation writes a member for the
521 // session in hand; there is no session in hand. The page says
522 // so rather than pretending the token is bad — and it says it
523 // at 403, because this is a refusal and a 200 would let a
524 // caller read "sign in first" as "accepted". The token is not
525 // touched, so the invitee can redeem it once they have a
526 // session.
527 w.WriteHeader(http.StatusForbidden)
528 h.cfg.RenderInvitation(w, r, InvitationPage{
529 Site: h.site(r),
530 Token: token,
531 Error: signInFirstCopy,
532 })
533 return
534 }
535
536 // 1 and 2: an existing row, active or not.
537 switch m, err := rs.BySubject(ctx, subject); {
538 case err == nil && m.Active():
539 h.done(w, r, alreadyNotice)
540 return
541 case err == nil:
542 h.log().Info("idear: a deactivated member tried to redeem an invitation; readmission is Restore",
543 "subject", subject, "member_id", m.ID)
544 h.deadInvitation(w, r, ErrNoInvitation, true)
545 return
546 case errors.Is(err, ErrNotFound):
547 // The orphan. Carry on.
548 default:
549 h.log().Error("idear: reconciliation could not resolve the viewer", "subject", subject, "err", err)
550 h.failInvitation(w, r, token)
551 return
552 }
553
554 // 3: the unclaimed instance.
555 empty, err := rs.IsEmpty(ctx)
556 if err != nil {
557 h.log().Error("idear: reconciliation could not count the roster", "subject", subject, "err", err)
558 h.failInvitation(w, r, token)
559 return
560 }
561 if empty {
562 switch _, err := rs.Claim(ctx, subject, addressOf(subject), ""); {
563 case err == nil:
564 h.log().Info("idear: reconciliation claimed the instance for a signed-in orphan", "subject", subject)
565 h.done(w, r, joinedNotice)
566 return
567 case errors.Is(err, ErrOwnerExists):
568 // Somebody claimed it between the count and here. They
569 // may still hold an invitation of their own; fall through.
570 default:
571 h.log().Error("idear: reconciliation could not claim the instance", "subject", subject, "err", err)
572 h.failInvitation(w, r, token)
573 return
574 }
575 }
576
577 // 4: the token — plus the email match, WHEREVER IDEAR CAN MAKE
578 // ONE.
579 //
580 // It can when the session Subject IS an address, which is what
581 // keymail mints; and it can under password when the app supplied
582 // Config.EmailForSubject to resolve its own user id. Either way
583 // the rule is the one admission applies: possession of the token
584 // AND an email match.
585 //
586 // With NEITHER — the password path, no resolver — the token alone
587 // is the credential here, and that is not parity with admission,
588 // which always has a submitted address to match against. A
589 // signed-in orphan can then spend any live token they get hold
590 // of, at whatever role it carries. Config.EmailForSubject states
591 // that asymmetry in full; it is written down rather than papered
592 // over, and setting the hook closes it.
593 addr, known, err := rs.addressFor(ctx, subject)
594 if err != nil {
595 h.log().Error("idear: reconciliation could not resolve the viewer's address; refusing",
596 "subject", subject, "err", err)
597 h.failInvitation(w, r, token)
598 return
599 }
600 if known {
601 inv, err := rs.pendingInvitation(ctx, token)
602 switch {
603 case err != nil:
604 // An unusable token, or a lookup that failed: one answer
605 // for all of them, as everywhere else on this route.
606 h.deadInvitation(w, r, ErrNoInvitation, true)
607 return
608 case addr == "":
609 // The resolver ran and placed this subject nowhere. Fail
610 // closed: a viewer idear cannot identify must not redeem
611 // an invitation issued to one it can.
612 h.log().Warn("idear: reconciliation could not place the viewer's subject in the app's own records; refusing",
613 "subject", subject)
614 h.deadInvitation(w, r, ErrNoInvitation, true)
615 return
616 case normalizeEmail(inv.Email) != addr:
617 h.log().Warn("idear: reconciliation presented an invitation issued to another address",
618 "subject", subject)
619 h.deadInvitation(w, r, ErrNoInvitation, true)
620 return
621 }
622 }
623
624 m, err := rs.Accept(ctx, token, subject, "")
625 if err != nil {
626 if !errors.Is(err, ErrNoInvitation) {
627 h.log().Error("idear: reconciliation could not redeem an invitation", "subject", subject, "err", err)
628 h.failInvitation(w, r, token)
629 return
630 }
631 h.deadInvitation(w, r, err, true)
632 return
633 }
634 h.log().Info("idear: reconciliation healed an orphan", "subject", subject, "member_id", m.ID, "role", string(m.Role))
635 h.done(w, r, joinedNotice)
636}
637
638// viewer is the guarded handlers' first line: the member Require
639// resolved.
640//
641// A nil viewer here is a MOUNT BUG — the handler ran outside Require —
642// and it is answered with the app's 404 and a loud log line rather
643// than a panic or, worse, a nil actor handed to the store. Every store
644// mutation refuses a nil actor, so this is defence in depth; what it
645// buys is a log line that names the cause.
646func (h *Handlers) viewer(w http.ResponseWriter, r *http.Request) (*Member, bool) {
647 m := From(r)
648 if m == nil {
649 h.log().Error("idear: a handler ran with no viewer; it must be mounted INSIDE Roster.Require",
650 "path", r.URL.Path)
651 h.cfg.Roster.cfg.NotFound(w, r)
652 return nil, false
653 }
654 return m, true
655}
656
657// target resolves the viewer and the {id} in the path to a member row.
658// A miss renders the app's own 404 — the same page a non-member gets,
659// through the same hook.
660func (h *Handlers) target(w http.ResponseWriter, r *http.Request) (*Member, *Member, bool) {
661 viewer, ok := h.viewer(w, r)
662 if !ok {
663 return nil, nil, false
664 }
665 id, err := pathID(r)
666 if err != nil {
667 h.refuse(w, r, viewer, err)
668 return nil, nil, false
669 }
670 target, err := h.cfg.Roster.ByID(r.Context(), id)
671 if err != nil {
672 h.refuse(w, r, viewer, err)
673 return nil, nil, false
674 }
675 return viewer, target, true
676}
677
678// done is the success path: flash a notice, then 303 back to the
679// members page so a refresh cannot repost the mutation.
680func (h *Handlers) done(w http.ResponseWriter, r *http.Request, notice string) {
681 h.flash(w, r, "notice", notice)
682}
683
684func (h *Handlers) flash(w http.ResponseWriter, r *http.Request, kind, msg string) {
685 flash.Set(w, kind, msg)
686 http.Redirect(w, r, h.cfg.MembersPath, http.StatusSeeOther)
687}
688
689// refuse renders a store refusal at the status its CLASS earns.
690//
691// The classes are the whole point of errors.go: ErrInvalid is 400,
692// ErrForbidden is 403 — and ErrLastOwner unwraps to ErrForbidden, so
693// this package's most security-relevant refusal renders 403 and not
694// 500 — ErrNotFound is the app's own 404 page, and anything left is a
695// 500 with no detail on it. Matching the CLASS with errors.Is rather
696// than the sentinel is what keeps a new sentinel from silently
697// becoming a server error.
698func (h *Handlers) refuse(w http.ResponseWriter, r *http.Request, viewer *Member, err error) {
699 switch {
700 case errors.Is(err, ErrNotFound):
701 // A member id that resolves to nothing is answered exactly as
702 // a non-member is: same hook, same bytes.
703 h.cfg.Roster.cfg.NotFound(w, r)
704 case errors.Is(err, ErrNoInvitation):
705 h.page(w, r, viewer, http.StatusNotFound, noInvitationCopy)
706 case errors.Is(err, ErrLastOwner):
707 // Checked before the ErrForbidden arm it unwraps to, because
708 // the reason is true for every actor at every rank and the
709 // generic copy would imply somebody senior could do it.
710 h.page(w, r, viewer, http.StatusForbidden, lastOwnerCopy)
711 case errors.Is(err, ErrInvalid):
712 h.page(w, r, viewer, http.StatusBadRequest, invalidCopy)
713 case errors.Is(err, ErrForbidden):
714 h.page(w, r, viewer, http.StatusForbidden, forbiddenCopy)
715 default:
716 h.log().Error("idear: a members mutation failed", "path", r.URL.Path, "err", err)
717 h.page(w, r, viewer, http.StatusInternalServerError, failedCopy)
718 }
719}
720
721// page renders the members page carrying an error, at status.
722//
723// The status is written BEFORE the renderer runs, so an app's renderer
724// must not write its own — the first WriteHeader wins and a second one
725// is a logged no-op. That is the cost of keeping Status off MembersPage;
726// what it buys is that a renderer cannot accidentally answer 200 to a
727// refusal.
728func (h *Handlers) page(w http.ResponseWriter, r *http.Request, viewer *Member, status int, msg string) {
729 d := h.membersPage(r.Context(), viewer)
730 d.Error = msg
731 w.WriteHeader(status)
732 h.cfg.RenderMembers(w, r, d)
733}
734
735// membersPage gathers what the members page shows. A listing failure
736// is logged and rendered as an EMPTY list rather than as a 500: this
737// is also the failure path's own renderer, and a refusal that turned
738// into a server error because the list behind it could not be read
739// would report the wrong problem.
740func (h *Handlers) membersPage(ctx context.Context, viewer *Member) MembersPage {
741 d := MembersPage{Viewer: viewer, Grantable: Grantable(viewer)}
742 members, err := h.cfg.Roster.Members(ctx)
743 if err != nil {
744 h.log().Error("idear: listing the roster failed", "err", err)
745 }
746 d.Members = members
747 invitations, err := h.cfg.Roster.PendingInvitations(ctx)
748 if err != nil {
749 h.log().Error("idear: listing pending invitations failed", "err", err)
750 }
751 d.Invitations = invitations
752 return d
753}
754
755// deadInvitation renders the one answer every unusable invitation
756// gets. A storage failure is NOT routed here — it renders failedCopy
757// instead, because telling someone their live invitation is dead
758// because the database hiccuped sends them to an admin for a new one
759// that will fail the same way.
760func (h *Handlers) deadInvitation(w http.ResponseWriter, r *http.Request, err error, signedIn bool) {
761 if !errors.Is(err, ErrNoInvitation) {
762 h.log().Error("idear: looking up an invitation failed", "err", err)
763 w.WriteHeader(http.StatusInternalServerError)
764 h.cfg.RenderInvitation(w, r, InvitationPage{Site: h.site(r), Error: failedCopy, SignedIn: signedIn})
765 return
766 }
767 w.WriteHeader(http.StatusNotFound)
768 h.cfg.RenderInvitation(w, r, InvitationPage{Site: h.site(r), Error: noInvitationCopy, SignedIn: signedIn})
769}
770
771// failInvitation is the invitation routes' 500: a storage failure, with
772// nothing about it on the page.
773func (h *Handlers) failInvitation(w http.ResponseWriter, r *http.Request, token string) {
774 w.WriteHeader(http.StatusInternalServerError)
775 h.cfg.RenderInvitation(w, r, InvitationPage{
776 Site: h.site(r),
777 Token: token,
778 Error: failedCopy,
779 SignedIn: true,
780 })
781}
782
783// standing answers the two questions the invitation page asks about
784// the viewer: are they signed in, and are they the orphan this route
785// is for. A deactivated member is NOT a reconcile candidate — they
786// have a row, and Accept refuses them.
787func (h *Handlers) standing(r *http.Request) (signedIn, reconcile bool) {
788 rs := h.cfg.Roster
789 subject, ok := rs.cfg.Subject(r)
790 if !ok || strings.TrimSpace(subject) == "" {
791 return false, false
792 }
793 switch _, err := rs.BySubject(r.Context(), subject); {
794 case err == nil:
795 return true, false
796 case errors.Is(err, ErrNotFound):
797 return true, true
798 default:
799 h.log().Error("idear: resolving the invitation viewer failed", "err", err)
800 return true, false
801 }
802}
803
804// allow spends one rate-limit token, answering 429 itself when there
805// is none. Plain text: this is the response an abusive client gets,
806// and rendering the app's own page for it would put a template render
807// on the cheapest path an attacker has.
808func (h *Handlers) allow(w http.ResponseWriter, r *http.Request) bool {
809 if h.limit.allow(h.cfg.ClientKey(r)) {
810 return true
811 }
812 h.log().Warn("idear: rate-limited a public invitation request", "path", r.URL.Path)
813 w.Header().Set("Retry-After", strconv.Itoa(int(h.limit.every.Seconds())))
814 http.Error(w, rateLimitedCopy, http.StatusTooManyRequests)
815 return false
816}
817
818// site names the instance on the invitation page. With no configured
819// Site this is the request's Host — a client-supplied header. See
820// HandlerConfig.Site.
821func (h *Handlers) site(r *http.Request) string {
822 if h.cfg.Site != "" {
823 return h.cfg.Site
824 }
825 return r.Host
826}
827
828// log is the Roster's logger. The handlers deliberately share it
829// rather than taking one of their own: the distinctions idear refuses
830// to render — which member was refused, whether a 404 was a stranger
831// or a deactivated member — are only ever visible in the log, and they
832// must all land in the same place.
833func (h *Handlers) log() *slog.Logger { return h.cfg.Roster.cfg.Logger }
834
835// field reads ONE named field from the POSTED BODY.
836//
837// By name, one at a time, never by binding a struct: a struct binding
838// accepts every field the struct has, and the fields idear's structs
839// have include Role and DeactivatedAt. It reads PostForm and not Form
840// as well, so a value in the QUERY STRING cannot stand in for a body
841// field — otherwise a link could carry ?role=owner into a POST whose
842// body never mentioned one.
843func field(r *http.Request, name string) string {
844 if r.PostForm == nil {
845 // An unparseable body leaves an empty form behind, which every
846 // caller here treats as a missing field: 400, not a default.
847 //
848 // MULTIPART IS PARSED EXPLICITLY. ParseForm does not populate
849 // PostForm for multipart/form-data — it parses only the query
850 // — so an app whose members form carries a file input would
851 // otherwise 400 on every submit with nothing to explain it.
852 // The failure direction was right and the diagnosis was
853 // impossible.
854 //
855 // The memory bound is small because nothing here reads a file:
856 // these are three short text fields. Anything larger spills to
857 // a temp file, which is why this runs only on the
858 // admin-guarded mutations — the public routes read no fields
859 // at all.
860 if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
861 _ = r.ParseMultipartForm(multipartMemory)
862 // Values are already copied into PostForm; nothing below
863 // reads a file, so the temp files this may have spilled
864 // need not outlive the request.
865 if r.MultipartForm != nil {
866 r.MultipartForm.RemoveAll()
867 }
868 } else {
869 _ = r.ParseForm()
870 }
871 }
872 return r.PostForm.Get(name)
873}
874
875// multipartMemory bounds what a multipart mutation may hold in memory.
876// idear reads only short text fields; the limit exists so a body that
877// is not that costs nothing.
878const multipartMemory = 1 << 20
879
880// pathID is the {id} wildcard, as an int64. Ids come from the URL on
881// every route that has one.
882func pathID(r *http.Request) (int64, error) {
883 return parseID(r.PathValue("id"))
884}
885
886func parseID(s string) (int64, error) {
887 id, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
888 if err != nil || id <= 0 {
889 return 0, ErrInvalidID
890 }
891 return id, nil
892}
893
894// addressOf returns subject when it is an ADDRESS — which is what
895// keymail mints as the session Subject — and "" when it is anything
896// else, such as password's decimal user id. It is the one place idear
897// decides whether it knows the viewer's address from the session
898// alone.
899func addressOf(subject string) string {
900 if strings.Contains(subject, "@") {
901 return subject
902 }
903 return ""
904}
905
906// addressFor answers what address a session Subject belongs to, and —
907// the part that matters — whether idear CAN know at all.
908//
909// known false is the honest "no idea": a subject that is not an
910// address, on an app that supplied no Config.EmailForSubject. The
911// caller must not read that as "no match"; it is the absence of a
912// question, and reconciliation's behaviour in that case is the
913// documented permissive path.
914//
915// known true with an empty address is a different answer: the app's
916// own resolver ran and placed the subject nowhere. That is a refusal,
917// not an unknown.
918//
919// The address is normalised on the way out so callers compare like
920// with like, and so an app may return whatever spelling its user table
921// happens to hold.
922func (rs *Roster) addressFor(ctx context.Context, subject string) (string, bool, error) {
923 if addr := addressOf(subject); addr != "" {
924 return normalizeEmail(addr), true, nil
925 }
926 if rs.cfg.EmailForSubject == nil {
927 return "", false, nil
928 }
929 addr, err := rs.cfg.EmailForSubject(ctx, subject)
930 if err != nil {
931 return "", true, err
932 }
933 return normalizeEmail(addr), true, nil
934}
935