| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | |
| 6 | "amadan.net/oficina/estilo.git/chrome" |
| 7 | "amadan.net/oficina/estilo.git/sky" |
| 8 | "amadan.net/oficina/home/connect" |
| 9 | rauth "amadan.net/rastrillo/rastrillo/auth" |
| 10 | ) |
| 11 | |
| 12 | // The sign-in pages are the Oficina door (estilo chrome): one card on the |
| 13 | // suite's sky, Calendar's own mark and name at its head, in Calendar's |
| 14 | // accent. The door's words are estilo's (chrome.English), not Calendar's; |
| 15 | // every outcome and redirect below is the one the old page had. |
| 16 | |
| 17 | // signin serves GET /signin — the form, and the landing spot for every |
| 18 | // outcome the auth plugin's own handlers redirect back to. The plugin |
| 19 | // never renders a page itself: it reports outcomes by redirecting here |
| 20 | // with a query this handler reads. |
| 21 | // |
| 22 | // Every outcome on this page means the same operational thing — "sign |
| 23 | // in did not complete, ask for a new link" — so the copy stays specific |
| 24 | // where it can be and shares one honest sentence where it cannot. The |
| 25 | // plugin deliberately refuses to distinguish unknown, used and expired |
| 26 | // tokens from one another, so there is nothing finer this page could |
| 27 | // draw. |
| 28 | // |
| 29 | // ?sent=1 is the check-your-email card, whose link comes back to this |
| 30 | // same path — /signin, or /signin/local when Home sign-in is on — so a |
| 31 | // second link is one click away, as it was when the form stayed below |
| 32 | // the notice. |
| 33 | func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| 34 | h.renderSignin(w, r, http.StatusOK, "") |
| 35 | } |
| 36 | |
| 37 | // refusal is the one sentence the door says to an address that proved |
| 38 | // itself and is not admitted (handlers.refused). |
| 39 | const refusal = "That address can’t sign in to Calendar. Use a different address, or ask whoever runs this team to add it." |
| 40 | |
| 41 | // refused is auth's Refused hook: Keymail or an emailed link verified the |
| 42 | // address and the allowlist does not hold it. It answers with the door, |
| 43 | // carrying the refusal, in a 403 — no session is minted, and auth has |
| 44 | // already ended the attempt. |
| 45 | // |
| 46 | // Rendered, not redirected to ?err=refused: a query parameter is |
| 47 | // something anybody can type, and the sentence means "you proved this |
| 48 | // address and it is not admitted" (Astra, round 3 on #28). Only this hook |
| 49 | // can say it, and auth calls it only after the address is proved, so |
| 50 | // saying it is no membership oracle. |
| 51 | func (h *handlers) refused(w http.ResponseWriter, r *http.Request, _ rauth.Identity) { |
| 52 | w.Header().Set("Cache-Control", "no-store") |
| 53 | h.renderSignin(w, r, http.StatusForbidden, refusal) |
| 54 | } |
| 55 | |
| 56 | // door is the sign-in door's chrome for this instance: Calendar's mark |
| 57 | // and name on its sky. |
| 58 | func door(r *http.Request) chrome.SignIn { |
| 59 | return chrome.SignIn{ |
| 60 | Icon: assets.Path("static/brand/icon.svg"), |
| 61 | App: "Calendar", |
| 62 | Sky: sky.For(r.Host), |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // renderConfirm is auth's RenderConfirm: the page an emailed link opens |
| 67 | // on. GET /auth/verify spends nothing, so a mail-security scanner that |
| 68 | // fetches every link in an inbound message cannot spend this one; the |
| 69 | // door's single Sign in button POSTs the token back to d.Action, and |
| 70 | // that POST signs in. |
| 71 | // |
| 72 | // It is the door with the address field taken away — the same mark, |
| 73 | // sky, heading and Sign in label — so the person who clicked the link |
| 74 | // sees Calendar, not rastrillo's plain English fallback. auth has |
| 75 | // already set no-store, Referrer-Policy: strict-origin (the URL is a |
| 76 | // live credential) and the framing refusals; render adds nothing that |
| 77 | // replaces them. d.Token is untrusted query input and reaches the page |
| 78 | // only through html/template. |
| 79 | func (h *handlers) renderConfirm(w http.ResponseWriter, r *http.Request, d rauth.ConfirmPageData) { |
| 80 | c := signinConfirm{Door: door(r), Action: d.Action, Token: d.Token} |
| 81 | c.Door.Lede = "Select Sign in to finish signing in." |
| 82 | render(w, "signin", h.page(r, c.Door.Page(), signinData{Confirm: &c})) |
| 83 | } |
| 84 | |
| 85 | // signinConfirm is the confirm step's door: the chrome, and the form |
| 86 | // that posts the emailed token back. |
| 87 | type signinConfirm struct { |
| 88 | Door chrome.SignIn |
| 89 | Action, Token string |
| 90 | } |
| 91 | |
| 92 | // renderSignin draws the sign-in form in status. refusedText, when set, |
| 93 | // is handlers.refused's sentence and replaces whatever the query says: |
| 94 | // it is never read from the query. |
| 95 | func (h *handlers) renderSignin(w http.ResponseWriter, r *http.Request, status int, refusedText string) { |
| 96 | q := r.URL.Query() |
| 97 | form := door(r) |
| 98 | form.Action = "/signin" |
| 99 | form.OpenSource = &chrome.Link{Href: "/help/open-source", Label: "Open source"} |
| 100 | if q.Get("force") != "" { |
| 101 | // A failed Keymail exchange lands here as ?force=1&err=keymail. |
| 102 | // Posting force back asks for an emailed link instead, so the |
| 103 | // retry does not return to the Keymail that just failed. |
| 104 | form.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}} |
| 105 | } |
| 106 | switch q.Get("err") { |
| 107 | case "": |
| 108 | case "rate": |
| 109 | form.Error = "Too many attempts. Wait a few minutes, then try again." |
| 110 | case "address": |
| 111 | form.AddressError = "That does not look like an email address." |
| 112 | case "expired": |
| 113 | form.Error = "That link has expired. Ask for a new one." |
| 114 | case "keymail": |
| 115 | form.Error = "Your Keymail sign-in couldn’t be completed. Enter your email to get a link instead." |
| 116 | default: |
| 117 | form.Error = "That link did not work. Ask for a new one." |
| 118 | } |
| 119 | if refusedText != "" { |
| 120 | // Keymail or a link verified the address and the allowlist does |
| 121 | // not hold it, in this very response (handlers.refused). Never |
| 122 | // from the query. The form is the way on. |
| 123 | form.Error, form.AddressError = refusedText, "" |
| 124 | } |
| 125 | sent := q.Get("sent") == "1" |
| 126 | failed := form.Error != "" || form.AddressError != "" |
| 127 | switch { |
| 128 | case sent && !failed: |
| 129 | notice := chrome.SignInNotice{Icon: form.Icon, App: form.App, Sky: form.Sky}.Sent(r.URL.Path) |
| 130 | renderStatus(w, "signin", status, h.page(r, notice.Page(), signinData{Sent: ¬ice})) |
| 131 | case sent: |
| 132 | // Somebody who asks for a second link after a failed one should |
| 133 | // see both facts, not whichever the page happened to check first: |
| 134 | // the form with its error, and the door's own sent sentence as |
| 135 | // the lede. The only lede this page ever draws. |
| 136 | form.Lede = chrome.English.SentBody |
| 137 | fallthrough |
| 138 | default: |
| 139 | renderStatus(w, "signin", status, h.page(r, form.Page(), signinData{Form: &form})) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // signinData is the sign-in page's own view data: exactly one of the |
| 144 | // door cards. Continue is set beside Sent on the page between the form |
| 145 | // and Keymail (renderKeymail): its meta refresh goes on to Keymail. |
| 146 | // Confirm is the step an emailed link opens on (renderConfirm). |
| 147 | type signinData struct { |
| 148 | Form *chrome.SignIn |
| 149 | Sent *chrome.SignInNotice |
| 150 | SignedOut *chrome.SignInNotice |
| 151 | Confirm *signinConfirm |
| 152 | Continue *connect.OAuthData |
| 153 | } |
| 154 | |
| 155 | // renderKeymail is the page between the form and Keymail, in both modes |
| 156 | // (beginSignin): the door's own card saying where the person is going, |
| 157 | // a meta refresh that goes there by itself, and a link that does when |
| 158 | // the refresh is blocked. A navigation from a page, unlike a redirect |
| 159 | // from a form POST, is outside form-action. |
| 160 | func (h *handlers) renderKeymail(w http.ResponseWriter, r *http.Request, destination string) { |
| 161 | p := connect.OAuthPage(destination) |
| 162 | notice := chrome.SignInNotice{ |
| 163 | Icon: assets.Path("static/brand/icon.svg"), App: "Calendar", Sky: sky.For(r.Host), |
| 164 | Heading: p.Title, Body: "Taking you to Keymail to finish signing in.", |
| 165 | Link: &chrome.Link{Href: p.URL, Label: p.ActionLabel}, |
| 166 | } |
| 167 | render(w, "signin", h.page(r, p.Title, signinData{Sent: ¬ice, Continue: &p})) |
| 168 | } |
| 169 | |
| 170 | // signedOutDoor is where Home sign-out lands, drawn as the door: the |
| 171 | // door's own "You’re signed out" card, whose Sign in goes to signin |
| 172 | // (connect's Action). Home is never named to people. |
| 173 | func signedOutDoor(r *http.Request, signin string) chrome.SignInNotice { |
| 174 | return chrome.SignInNotice{ |
| 175 | Icon: assets.Path("static/brand/icon.svg"), |
| 176 | App: "Calendar", |
| 177 | Sky: sky.For(r.Host), |
| 178 | }.SignedOut(signin) |
| 179 | } |
| 180 | |
| 181 | // signedOut serves GET /signed-out without Home: the same estilo door Home |
| 182 | // mode's sign-out lands on, in the door's words, so signing out is a |
| 183 | // page that says so rather than the sign-in form again (or, before, a |
| 184 | // 404 for anyone who reached the address). |
| 185 | func (h *handlers) signedOut(w http.ResponseWriter, r *http.Request) { |
| 186 | w.Header().Set("Cache-Control", "no-store") |
| 187 | notice := signedOutDoor(r, "/signin") |
| 188 | render(w, "signin", h.page(r, notice.Page(), signinData{SignedOut: ¬ice})) |
| 189 | } |
| 190 | |
| 191 | // signOutToSignedOut is rastrillo's standalone Sign out with one change: |
| 192 | // where it lands. auth.Signout revokes the session and answers 303 to its |
| 193 | // SigninPath, which every other auth outcome also uses, so the path cannot |
| 194 | // change; this retargets that one redirect to /signed-out. |
| 195 | func signOutToSignedOut(signout http.HandlerFunc) http.HandlerFunc { |
| 196 | return func(w http.ResponseWriter, r *http.Request) { |
| 197 | signout(&signedOutRedirect{ResponseWriter: w}, r) |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | type signedOutRedirect struct{ http.ResponseWriter } |
| 202 | |
| 203 | func (s *signedOutRedirect) WriteHeader(code int) { |
| 204 | if code == http.StatusSeeOther && s.Header().Get("Location") == "/signin" { |
| 205 | s.Header().Set("Location", "/signed-out") |
| 206 | } |
| 207 | s.ResponseWriter.WriteHeader(code) |
| 208 | } |
| 209 | |