package idear import ( "context" "errors" "log/slog" "net/http" "strconv" "strings" "github.com/carlosframework/rastrillo/flash" ) // The copy every refusal renders. Fixed strings, never the error's own // text: a store error can carry a subject, an address or a driver // message, and a page that echoed it would publish them to whoever // provoked it. The log line gets the detail; the page gets the class. const ( invalidCopy = "That request was not valid." forbiddenCopy = "You may not do that." lastOwnerCopy = "The owner cannot be removed. Transfer ownership first." noInvitationCopy = "That invitation is no longer available." failedCopy = "Something went wrong. Please try again." signInFirstCopy = "Sign in first, then open this invitation link again." rateLimitedCopy = "Too many requests. Please wait a moment and try again." ) // The notices a successful mutation flashes. They are one-shot display // state (rastrillo/flash), read back by Members on the redirect that // follows. const ( invitedNotice = "Invitation created." deliveredNotice = "Invitation sent." undeliveredNotice = "The invitation was created but could not be sent. Revoke it and try again." revokedNotice = "Invitation revoked." roleNotice = "Role updated." removedNotice = "Member removed." restoredNotice = "Member restored." transferNotice = "Ownership transferred." joinedNotice = "Welcome — your membership is set up." alreadyNotice = "You are already a member here." ) // flashError is the flash Kind that lands in MembersPage.Error rather // than MembersPage.Notice. Anything else is a notice. const flashError = "error" // MembersPage is what RenderMembers receives. // // Grantable is the roles the VIEWER may hand out, and a role selector // must be built from it rather than from the three constants. An Admin // may grant only Member — checkInviteRole refuses a grant that is not // STRICTLY BELOW the actor's own rank — so a form that offered Admin // to an Admin would 403 on every submit, and one that offered Owner // would 403 for everybody including the Owner. It is computed by // Grantable, which asks the same predicate the store enforces, so the // selector cannot drift from the rule. type MembersPage struct { Viewer *Member Members []Member Invitations []Invitation Grantable []Role Error string Notice string } // InvitationPage is what RenderInvitation receives, on the public // invitation routes. // // THERE IS NO ADDRESS FIELD, and that is the design rather than an // omission. GET /invitations/{token} is an unauthenticated lookup of a // secret: it names the instance and the role so the holder knows what // they are accepting, and it must not echo the invited address, which // would turn a leaked or guessed link into a disclosure. A renderer // cannot print what it is never handed. // // SignedIn says the viewer has a session; Reconcile says they have one // AND no member row — the orphan POST is for. A template shows the // "accept" button to a Reconcile viewer, and a "sign up with this // link" form to everybody else. type InvitationPage struct { Role Role Site string Token string Error string SignedIn bool Reconcile bool } // HandlerConfig wires the flows to the app's own shell. Roster and // both renderers are required; everything else has a serviceable // default. // // There is deliberately NO NotFound here, though the design sketch // shows one: the 404 renderer lives on idear.Config, because Require // is a method on the Roster and answers non-members long before a // handler runs. Two hooks would mean two 404 pages, and a 404 that // varies with which layer refused is the membership oracle the whole // design is arranged to avoid. Set Config.NotFound to the same // renderer the app gives chi's own NotFound and every refusal in idear // renders through it. type HandlerConfig struct { Roster *Roster RenderMembers func(w http.ResponseWriter, r *http.Request, d MembersPage) RenderInvitation func(w http.ResponseWriter, r *http.Request, d InvitationPage) // Site names this instance on the invitation page — "Acme's // board". // // SET IT IN PRODUCTION. The default is the request's Host, and // Host is a CLIENT-SUPPLIED HEADER: on the public, unauthenticated // invitation page, an attacker who mails somebody a link and can // steer the Host (a permissive reverse proxy, a wildcard vhost, // a raw request) has the page render text of their choosing as // the instance's name — "Acme Security, verify your password at // ...". idear escapes nothing on the app's behalf either; the // renderer owns that. The default is honest about where the // request landed, which is useful in development and is not a // name you should show a stranger. Site string // MembersPath is where a successful mutation redirects. Default // "/members". An app that mounts the members page elsewhere must // say so here, or every 303 lands on a 404. MembersPath string // InvitationPath is the prefix a redeemable link is built from: // InvitationPath + token. Default "/invitations/". InvitationPath string // Deliver mails the invitation link. It is given the request (for // an origin, a locale, a logger), the invitation and the link. // // When it is nil idear puts the LINK ITSELF in the flash notice, // so a bare mount is usable: the token is shown exactly once, to // the admin who minted it, and can then be pasted into whatever // channel the team uses. // // That fallback puts a live credential in a cookie, and // rastrillo/flash sets HttpOnly, SameSite=Lax and MaxAge=60 but // NOT Secure — so on a plain-http origin the token crosses the // wire in clear text, and it is written by a package idear does // not own, so idear cannot add the flag. An app that can send // mail should set this and keep the token out of the browser // entirely; NewHandlers logs a warning when it is nil. Deliver func(r *http.Request, inv *Invitation, link string) error // RateLimit bounds the two public routes. See RateLimit — the // zero value is the documented default, and there is no way to // turn the limiter off. RateLimit RateLimit // ClientKey is the rate-limit key for a request. Default: the IP // half of RemoteAddr. Behind a reverse proxy, set this to read // the app's own TRUSTED forwarding header — see clientIP for why // idear will not guess at one. ClientKey func(r *http.Request) string } // Handlers is idear's HTTP surface: the members page, the six // management mutations, and the two public invitation routes. Build // one at boot and mount Routes. type Handlers struct { cfg HandlerConfig limit *limiter } // NewHandlers validates cfg and returns the handlers. It errors unless // the Roster and BOTH renderers are set, following jobs.NewHandlers: // a nil renderer is a nil call in a request, and a boot error is a // better place to learn about it than a production panic. func NewHandlers(cfg HandlerConfig) (*Handlers, error) { if cfg.Roster == nil { return nil, errors.New("idear: HandlerConfig.Roster is required") } if cfg.RenderMembers == nil { return nil, errors.New("idear: HandlerConfig.RenderMembers is required") } if cfg.RenderInvitation == nil { return nil, errors.New("idear: HandlerConfig.RenderInvitation is required") } if cfg.MembersPath == "" { cfg.MembersPath = "/members" } if cfg.InvitationPath == "" { cfg.InvitationPath = "/invitations/" } if cfg.ClientKey == nil { cfg.ClientKey = clientIP } if cfg.Deliver == nil { // Once, at boot, rather than on every invitation: the // fallback is usable and it is not what a deployed app should // be doing. See HandlerConfig.Deliver. 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") } return &Handlers{cfg: cfg, limit: newLimiter(cfg.RateLimit)}, nil } // Route is one mounted handler: the method and pattern idear suggests, // the handler ALREADY WRAPPED in the middleware it requires, and the // rank that wrapping enforces. // // The handler is pre-guarded on purpose. Require and RequireRole have // a stacking order that is wrong in two different ways when it is got // wrong (RequireRole mounted bare 403s a stranger, which tells them // the route exists; Require mounted outside the app's session guard // 404s everyone including the Owner), so Routes hands out the correct // composition rather than a bare handler and a comment. What an app // still owns is the paths and the session guard these are mounted // inside. // // Min is the rank floor the handler enforces, and Public says the // route is unauthenticated. They are not decoration: they let a // caller — an app's route audit, or idear's own authorization suite — // DERIVE what each route should refuse instead of restating a list // that can drift from the one being mounted. type Route struct { Method string Pattern string Handler http.Handler Min Role Public bool } // Routes is the whole HTTP surface, in the order the design lists it. // // The patterns are defaults; paths belong to the app. An app that // mounts them elsewhere must set MembersPath and InvitationPath to // match, since those are what the redirects and the invitation links // are built from. func (h *Handlers) Routes() []Route { rs := h.cfg.Roster guard := func(min Role, fn http.HandlerFunc) http.Handler { return rs.Require(rs.RequireRole(min)(fn)) } return []Route{ {http.MethodGet, "/members", guard(RoleMember, h.Members), RoleMember, false}, {http.MethodPost, "/members/invitations", guard(RoleAdmin, h.Invite), RoleAdmin, false}, {http.MethodPost, "/members/invitations/{id}/revoke", guard(RoleAdmin, h.Revoke), RoleAdmin, false}, {http.MethodPost, "/members/{id}/role", guard(RoleAdmin, h.SetRole), RoleAdmin, false}, {http.MethodPost, "/members/{id}/remove", guard(RoleAdmin, h.Remove), RoleAdmin, false}, {http.MethodPost, "/members/{id}/restore", guard(RoleAdmin, h.Restore), RoleAdmin, false}, {http.MethodPost, "/members/transfer", guard(RoleOwner, h.Transfer), RoleOwner, false}, {http.MethodGet, "/invitations/{token}", http.HandlerFunc(h.Invitation), "", true}, {http.MethodPost, "/invitations/{token}", http.HandlerFunc(h.Accept), "", true}, } } // Grantable is the roles actor may hand out, highest first — what a // role selector must be built from. // // It is derived from the store's OWN predicates and never from a // second copy of the rule: mayManage is the authority floor Invite and // SetRole check first (active, and at least Admin), and // checkInviteRole is the rank rule they check next, both inside their // transactions. So the form and the store cannot disagree, and a // deactivated actor — whose row outlives their privileges, because // removal is never a delete — is offered nothing at all. RoleOwner is // never in it for anybody: ownership moves only by Transfer. func Grantable(actor *Member) []Role { if actor == nil || mayManage(actor) != nil { return nil } var out []Role for _, role := range []Role{RoleOwner, RoleAdmin, RoleMember} { if checkInviteRole(actor, role) == nil { out = append(out, role) } } return out } // Members is GET /members: the roster, the pending invitations, and // whatever the last mutation flashed. func (h *Handlers) Members(w http.ResponseWriter, r *http.Request) { viewer, ok := h.viewer(w, r) if !ok { return } d := h.membersPage(r.Context(), viewer) if f, ok := flash.Take(w, r); ok { if f.Kind == flashError { d.Error = f.Message } else { d.Notice = f.Message } } h.cfg.RenderMembers(w, r, d) } // Invite is POST /members/invitations. // // The role IS read from the form here, and that is safe for exactly // one reason: checkInviteRole refuses RoleOwner outright, for every // actor including the Owner, and refuses anything not strictly below // the actor's own rank. A posted role can therefore only ever be worth // LESS than the poster already holds. Every other field is read by // name too — there is no struct binding anywhere in this file, so a // field nobody named cannot arrive. func (h *Handlers) Invite(w http.ResponseWriter, r *http.Request) { viewer, ok := h.viewer(w, r) if !ok { return } role, valid := ParseRole(field(r, "role")) if !valid { h.refuse(w, r, viewer, ErrInvalidRole) return } inv, token, err := h.cfg.Roster.Invite(r.Context(), viewer, field(r, "email"), role) if err != nil { h.refuse(w, r, viewer, err) return } link := h.cfg.InvitationPath + token if h.cfg.Deliver == nil { // No delivery hook: the link is the notice. See // HandlerConfig.Deliver. h.done(w, r, invitedNotice+" "+link) return } if err := h.cfg.Deliver(r, inv, link); err != nil { h.log().Error("idear: an invitation was created but could not be delivered", "invitation_id", inv.ID, "err", err) h.flash(w, r, flashError, undeliveredNotice) return } h.done(w, r, deliveredNotice) } // Revoke is POST /members/invitations/{id}/revoke. The id comes from // the URL, never from the body. func (h *Handlers) Revoke(w http.ResponseWriter, r *http.Request) { viewer, ok := h.viewer(w, r) if !ok { return } id, err := pathID(r) if err != nil { h.refuse(w, r, viewer, err) return } if err := h.cfg.Roster.Revoke(r.Context(), viewer, id); err != nil { h.refuse(w, r, viewer, err) return } h.done(w, r, revokedNotice) } // SetRole is POST /members/{id}/role — the target from the URL, the // new role from the form. See Invite for why a posted role is safe // here and cannot reach Owner. func (h *Handlers) SetRole(w http.ResponseWriter, r *http.Request) { viewer, target, ok := h.target(w, r) if !ok { return } role, valid := ParseRole(field(r, "role")) if !valid { h.refuse(w, r, viewer, ErrInvalidRole) return } if err := h.cfg.Roster.SetRole(r.Context(), viewer, target, role); err != nil { h.refuse(w, r, viewer, err) return } h.done(w, r, roleNotice) } // Remove is POST /members/{id}/remove: deactivation, never a delete. func (h *Handlers) Remove(w http.ResponseWriter, r *http.Request) { viewer, target, ok := h.target(w, r) if !ok { return } if err := h.cfg.Roster.Deactivate(r.Context(), viewer, target); err != nil { h.refuse(w, r, viewer, err) return } h.done(w, r, removedNotice) } // Restore is POST /members/{id}/restore: readmission at the role the // row still carries. It is the only way back in for someone who was // removed — see Roster.Reactivate. func (h *Handlers) Restore(w http.ResponseWriter, r *http.Request) { viewer, target, ok := h.target(w, r) if !ok { return } if err := h.cfg.Roster.Reactivate(r.Context(), viewer, target); err != nil { h.refuse(w, r, viewer, err) return } h.done(w, r, restoredNotice) } // Transfer is POST /members/transfer, the one mutation whose target id // comes from the FORM and not the URL: the path has no id in it, // because the actor is always the current Owner and the route is not // addressed by them. // // No role is read here at all. Transfer is the only path to RoleOwner, // and it decides both roles itself. func (h *Handlers) Transfer(w http.ResponseWriter, r *http.Request) { viewer, ok := h.viewer(w, r) if !ok { return } id, err := parseID(field(r, "member")) if err != nil { h.refuse(w, r, viewer, err) return } target, err := h.cfg.Roster.ByID(r.Context(), id) if err != nil { h.refuse(w, r, viewer, err) return } if err := h.cfg.Roster.Transfer(r.Context(), viewer, target); err != nil { h.refuse(w, r, viewer, err) return } h.done(w, r, transferNotice) } // Invitation is GET /invitations/{token}: PUBLIC, unauthenticated, and // a lookup of a secret. // // It renders the ROLE and the INSTANCE, and never the invited address: // InvitationPage has no field for one. Every unusable token — no such // token, spent, revoked, expired — renders the SAME copy at the same // status, because ErrNoInvitation is deliberately one error and not // four, and a page that told them apart would answer questions the // holder is not entitled to ask. // // It is rate-limited. See RateLimit. func (h *Handlers) Invitation(w http.ResponseWriter, r *http.Request) { if !h.allow(w, r) { return } token := r.PathValue("token") signedIn, reconcile := h.standing(r) inv, err := h.cfg.Roster.pendingInvitation(r.Context(), token) if err != nil { h.deadInvitation(w, r, err, signedIn) return } h.cfg.RenderInvitation(w, r, InvitationPage{ Role: inv.Role, Site: h.site(r), Token: token, SignedIn: signedIn, Reconcile: reconcile, }) } // Accept is POST /invitations/{token}: the RECONCILIATION route, and // the only path by which a member row is created from an already-live // session. // // It exists because admission cannot be one transaction. Roster. // Admitting calls the app's opaque Create and then writes the member; // a failure between them leaves a user row with no membership, and a // retry does NOT heal it — the retry's Create fails on the now // duplicate email and password.Signup renders that as 422 without ever // reaching the member write. The orphan can sign IN and 404s on every // guarded route forever. Two concurrent first signups reach the same // place with no failure at all: one wins the Claim and the // ErrOwnerExists loser is an orphan. // // What it does, in order, for a viewer who is SIGNED IN: // // 1. already a member: nothing to reconcile — a notice and a // redirect, and the token is NOT spent. // 2. a DEACTIVATED member: refused. Readmission is Restore, by an // admin; an invitation must not be a way for a removed person to // let themselves back in. // 3. the roster has ZERO ROWS: Claim, exactly as admission would // have. An unclaimed instance's first account is its Owner, and // the only way to be signed in against an empty roster is to be // the orphan whose Claim did not commit. // 4. otherwise: the token must be REDEEMABLE BY THIS VIEWER — // issued to their address, wherever idear can learn it (keymail's // Subject is the address; under password, Config.EmailForSubject // resolves one) — and then Accept it, through the CAS in the // store, so a Revoke racing this never admits. Under password // with no resolver there is no address to match and the token // alone decides; that gap is Config.EmailForSubject's whole // subject matter. // // The member row is written by the STORE, from the live session // Subject, and never assembled here: Subject is canonicalised on every // write (normalizeSubject), and under keymail the raw subject is the // address as the visitor typed it. A Member literal built in this // handler would reintroduce exactly the bug that canonicalisation // exists to close. // // It needs a VALID TOKEN. A claim-race loser holds none, so they are // healed only after somebody invites them — at which point they redeem // here rather than through sign-up, which would fail on the duplicate // email. That is the design spec's corrected wording and it is the // reason this route is not a general "make me a member" button. // // It is rate-limited. See RateLimit. func (h *Handlers) Accept(w http.ResponseWriter, r *http.Request) { if !h.allow(w, r) { return } token := r.PathValue("token") rs := h.cfg.Roster ctx := r.Context() subject, ok := rs.cfg.Subject(r) subject = strings.TrimSpace(subject) if !ok || subject == "" { // Not signed in. Reconciliation writes a member for the // session in hand; there is no session in hand. The page says // so rather than pretending the token is bad — and it says it // at 403, because this is a refusal and a 200 would let a // caller read "sign in first" as "accepted". The token is not // touched, so the invitee can redeem it once they have a // session. w.WriteHeader(http.StatusForbidden) h.cfg.RenderInvitation(w, r, InvitationPage{ Site: h.site(r), Token: token, Error: signInFirstCopy, }) return } // 1 and 2: an existing row, active or not. switch m, err := rs.BySubject(ctx, subject); { case err == nil && m.Active(): h.done(w, r, alreadyNotice) return case err == nil: h.log().Info("idear: a deactivated member tried to redeem an invitation; readmission is Restore", "subject", subject, "member_id", m.ID) h.deadInvitation(w, r, ErrNoInvitation, true) return case errors.Is(err, ErrNotFound): // The orphan. Carry on. default: h.log().Error("idear: reconciliation could not resolve the viewer", "subject", subject, "err", err) h.failInvitation(w, r, token) return } // 3: the unclaimed instance. empty, err := rs.IsEmpty(ctx) if err != nil { h.log().Error("idear: reconciliation could not count the roster", "subject", subject, "err", err) h.failInvitation(w, r, token) return } if empty { switch _, err := rs.Claim(ctx, subject, addressOf(subject), ""); { case err == nil: h.log().Info("idear: reconciliation claimed the instance for a signed-in orphan", "subject", subject) h.done(w, r, joinedNotice) return case errors.Is(err, ErrOwnerExists): // Somebody claimed it between the count and here. They // may still hold an invitation of their own; fall through. default: h.log().Error("idear: reconciliation could not claim the instance", "subject", subject, "err", err) h.failInvitation(w, r, token) return } } // 4: the token — plus the email match, WHEREVER IDEAR CAN MAKE // ONE. // // It can when the session Subject IS an address, which is what // keymail mints; and it can under password when the app supplied // Config.EmailForSubject to resolve its own user id. Either way // the rule is the one admission applies: possession of the token // AND an email match. // // With NEITHER — the password path, no resolver — the token alone // is the credential here, and that is not parity with admission, // which always has a submitted address to match against. A // signed-in orphan can then spend any live token they get hold // of, at whatever role it carries. Config.EmailForSubject states // that asymmetry in full; it is written down rather than papered // over, and setting the hook closes it. addr, known, err := rs.addressFor(ctx, subject) if err != nil { h.log().Error("idear: reconciliation could not resolve the viewer's address; refusing", "subject", subject, "err", err) h.failInvitation(w, r, token) return } if known { inv, err := rs.pendingInvitation(ctx, token) switch { case err != nil: // An unusable token, or a lookup that failed: one answer // for all of them, as everywhere else on this route. h.deadInvitation(w, r, ErrNoInvitation, true) return case addr == "": // The resolver ran and placed this subject nowhere. Fail // closed: a viewer idear cannot identify must not redeem // an invitation issued to one it can. h.log().Warn("idear: reconciliation could not place the viewer's subject in the app's own records; refusing", "subject", subject) h.deadInvitation(w, r, ErrNoInvitation, true) return case normalizeEmail(inv.Email) != addr: h.log().Warn("idear: reconciliation presented an invitation issued to another address", "subject", subject) h.deadInvitation(w, r, ErrNoInvitation, true) return } } m, err := rs.Accept(ctx, token, subject, "") if err != nil { if !errors.Is(err, ErrNoInvitation) { h.log().Error("idear: reconciliation could not redeem an invitation", "subject", subject, "err", err) h.failInvitation(w, r, token) return } h.deadInvitation(w, r, err, true) return } h.log().Info("idear: reconciliation healed an orphan", "subject", subject, "member_id", m.ID, "role", string(m.Role)) h.done(w, r, joinedNotice) } // viewer is the guarded handlers' first line: the member Require // resolved. // // A nil viewer here is a MOUNT BUG — the handler ran outside Require — // and it is answered with the app's 404 and a loud log line rather // than a panic or, worse, a nil actor handed to the store. Every store // mutation refuses a nil actor, so this is defence in depth; what it // buys is a log line that names the cause. func (h *Handlers) viewer(w http.ResponseWriter, r *http.Request) (*Member, bool) { m := From(r) if m == nil { h.log().Error("idear: a handler ran with no viewer; it must be mounted INSIDE Roster.Require", "path", r.URL.Path) h.cfg.Roster.cfg.NotFound(w, r) return nil, false } return m, true } // target resolves the viewer and the {id} in the path to a member row. // A miss renders the app's own 404 — the same page a non-member gets, // through the same hook. func (h *Handlers) target(w http.ResponseWriter, r *http.Request) (*Member, *Member, bool) { viewer, ok := h.viewer(w, r) if !ok { return nil, nil, false } id, err := pathID(r) if err != nil { h.refuse(w, r, viewer, err) return nil, nil, false } target, err := h.cfg.Roster.ByID(r.Context(), id) if err != nil { h.refuse(w, r, viewer, err) return nil, nil, false } return viewer, target, true } // done is the success path: flash a notice, then 303 back to the // members page so a refresh cannot repost the mutation. func (h *Handlers) done(w http.ResponseWriter, r *http.Request, notice string) { h.flash(w, r, "notice", notice) } func (h *Handlers) flash(w http.ResponseWriter, r *http.Request, kind, msg string) { flash.Set(w, kind, msg) http.Redirect(w, r, h.cfg.MembersPath, http.StatusSeeOther) } // refuse renders a store refusal at the status its CLASS earns. // // The classes are the whole point of errors.go: ErrInvalid is 400, // ErrForbidden is 403 — and ErrLastOwner unwraps to ErrForbidden, so // this package's most security-relevant refusal renders 403 and not // 500 — ErrNotFound is the app's own 404 page, and anything left is a // 500 with no detail on it. Matching the CLASS with errors.Is rather // than the sentinel is what keeps a new sentinel from silently // becoming a server error. func (h *Handlers) refuse(w http.ResponseWriter, r *http.Request, viewer *Member, err error) { switch { case errors.Is(err, ErrNotFound): // A member id that resolves to nothing is answered exactly as // a non-member is: same hook, same bytes. h.cfg.Roster.cfg.NotFound(w, r) case errors.Is(err, ErrNoInvitation): h.page(w, r, viewer, http.StatusNotFound, noInvitationCopy) case errors.Is(err, ErrLastOwner): // Checked before the ErrForbidden arm it unwraps to, because // the reason is true for every actor at every rank and the // generic copy would imply somebody senior could do it. h.page(w, r, viewer, http.StatusForbidden, lastOwnerCopy) case errors.Is(err, ErrInvalid): h.page(w, r, viewer, http.StatusBadRequest, invalidCopy) case errors.Is(err, ErrForbidden): h.page(w, r, viewer, http.StatusForbidden, forbiddenCopy) default: h.log().Error("idear: a members mutation failed", "path", r.URL.Path, "err", err) h.page(w, r, viewer, http.StatusInternalServerError, failedCopy) } } // page renders the members page carrying an error, at status. // // The status is written BEFORE the renderer runs, so an app's renderer // must not write its own — the first WriteHeader wins and a second one // is a logged no-op. That is the cost of keeping Status off MembersPage; // what it buys is that a renderer cannot accidentally answer 200 to a // refusal. func (h *Handlers) page(w http.ResponseWriter, r *http.Request, viewer *Member, status int, msg string) { d := h.membersPage(r.Context(), viewer) d.Error = msg w.WriteHeader(status) h.cfg.RenderMembers(w, r, d) } // membersPage gathers what the members page shows. A listing failure // is logged and rendered as an EMPTY list rather than as a 500: this // is also the failure path's own renderer, and a refusal that turned // into a server error because the list behind it could not be read // would report the wrong problem. func (h *Handlers) membersPage(ctx context.Context, viewer *Member) MembersPage { d := MembersPage{Viewer: viewer, Grantable: Grantable(viewer)} members, err := h.cfg.Roster.Members(ctx) if err != nil { h.log().Error("idear: listing the roster failed", "err", err) } d.Members = members invitations, err := h.cfg.Roster.PendingInvitations(ctx) if err != nil { h.log().Error("idear: listing pending invitations failed", "err", err) } d.Invitations = invitations return d } // deadInvitation renders the one answer every unusable invitation // gets. A storage failure is NOT routed here — it renders failedCopy // instead, because telling someone their live invitation is dead // because the database hiccuped sends them to an admin for a new one // that will fail the same way. func (h *Handlers) deadInvitation(w http.ResponseWriter, r *http.Request, err error, signedIn bool) { if !errors.Is(err, ErrNoInvitation) { h.log().Error("idear: looking up an invitation failed", "err", err) w.WriteHeader(http.StatusInternalServerError) h.cfg.RenderInvitation(w, r, InvitationPage{Site: h.site(r), Error: failedCopy, SignedIn: signedIn}) return } w.WriteHeader(http.StatusNotFound) h.cfg.RenderInvitation(w, r, InvitationPage{Site: h.site(r), Error: noInvitationCopy, SignedIn: signedIn}) } // failInvitation is the invitation routes' 500: a storage failure, with // nothing about it on the page. func (h *Handlers) failInvitation(w http.ResponseWriter, r *http.Request, token string) { w.WriteHeader(http.StatusInternalServerError) h.cfg.RenderInvitation(w, r, InvitationPage{ Site: h.site(r), Token: token, Error: failedCopy, SignedIn: true, }) } // standing answers the two questions the invitation page asks about // the viewer: are they signed in, and are they the orphan this route // is for. A deactivated member is NOT a reconcile candidate — they // have a row, and Accept refuses them. func (h *Handlers) standing(r *http.Request) (signedIn, reconcile bool) { rs := h.cfg.Roster subject, ok := rs.cfg.Subject(r) if !ok || strings.TrimSpace(subject) == "" { return false, false } switch _, err := rs.BySubject(r.Context(), subject); { case err == nil: return true, false case errors.Is(err, ErrNotFound): return true, true default: h.log().Error("idear: resolving the invitation viewer failed", "err", err) return true, false } } // allow spends one rate-limit token, answering 429 itself when there // is none. Plain text: this is the response an abusive client gets, // and rendering the app's own page for it would put a template render // on the cheapest path an attacker has. func (h *Handlers) allow(w http.ResponseWriter, r *http.Request) bool { if h.limit.allow(h.cfg.ClientKey(r)) { return true } h.log().Warn("idear: rate-limited a public invitation request", "path", r.URL.Path) w.Header().Set("Retry-After", strconv.Itoa(int(h.limit.every.Seconds()))) http.Error(w, rateLimitedCopy, http.StatusTooManyRequests) return false } // site names the instance on the invitation page. With no configured // Site this is the request's Host — a client-supplied header. See // HandlerConfig.Site. func (h *Handlers) site(r *http.Request) string { if h.cfg.Site != "" { return h.cfg.Site } return r.Host } // log is the Roster's logger. The handlers deliberately share it // rather than taking one of their own: the distinctions idear refuses // to render — which member was refused, whether a 404 was a stranger // or a deactivated member — are only ever visible in the log, and they // must all land in the same place. func (h *Handlers) log() *slog.Logger { return h.cfg.Roster.cfg.Logger } // field reads ONE named field from the POSTED BODY. // // By name, one at a time, never by binding a struct: a struct binding // accepts every field the struct has, and the fields idear's structs // have include Role and DeactivatedAt. It reads PostForm and not Form // as well, so a value in the QUERY STRING cannot stand in for a body // field — otherwise a link could carry ?role=owner into a POST whose // body never mentioned one. func field(r *http.Request, name string) string { if r.PostForm == nil { // An unparseable body leaves an empty form behind, which every // caller here treats as a missing field: 400, not a default. // // MULTIPART IS PARSED EXPLICITLY. ParseForm does not populate // PostForm for multipart/form-data — it parses only the query // — so an app whose members form carries a file input would // otherwise 400 on every submit with nothing to explain it. // The failure direction was right and the diagnosis was // impossible. // // The memory bound is small because nothing here reads a file: // these are three short text fields. Anything larger spills to // a temp file, which is why this runs only on the // admin-guarded mutations — the public routes read no fields // at all. if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { _ = r.ParseMultipartForm(multipartMemory) // Values are already copied into PostForm; nothing below // reads a file, so the temp files this may have spilled // need not outlive the request. if r.MultipartForm != nil { r.MultipartForm.RemoveAll() } } else { _ = r.ParseForm() } } return r.PostForm.Get(name) } // multipartMemory bounds what a multipart mutation may hold in memory. // idear reads only short text fields; the limit exists so a body that // is not that costs nothing. const multipartMemory = 1 << 20 // pathID is the {id} wildcard, as an int64. Ids come from the URL on // every route that has one. func pathID(r *http.Request) (int64, error) { return parseID(r.PathValue("id")) } func parseID(s string) (int64, error) { id, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64) if err != nil || id <= 0 { return 0, ErrInvalidID } return id, nil } // addressOf returns subject when it is an ADDRESS — which is what // keymail mints as the session Subject — and "" when it is anything // else, such as password's decimal user id. It is the one place idear // decides whether it knows the viewer's address from the session // alone. func addressOf(subject string) string { if strings.Contains(subject, "@") { return subject } return "" } // addressFor answers what address a session Subject belongs to, and — // the part that matters — whether idear CAN know at all. // // known false is the honest "no idea": a subject that is not an // address, on an app that supplied no Config.EmailForSubject. The // caller must not read that as "no match"; it is the absence of a // question, and reconciliation's behaviour in that case is the // documented permissive path. // // known true with an empty address is a different answer: the app's // own resolver ran and placed the subject nowhere. That is a refusal, // not an unknown. // // The address is normalised on the way out so callers compare like // with like, and so an app may return whatever spelling its user table // happens to hold. func (rs *Roster) addressFor(ctx context.Context, subject string) (string, bool, error) { if addr := addressOf(subject); addr != "" { return normalizeEmail(addr), true, nil } if rs.cfg.EmailForSubject == nil { return "", false, nil } addr, err := rs.cfg.EmailForSubject(ctx, subject) if err != nil { return "", true, err } return normalizeEmail(addr), true, nil }