oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
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.
33func (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).
39const 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.
51func (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.
58func 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.
79func (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.
87type 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.
95func (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: &notice}))
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).
147type 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.
160func (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: &notice, 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.
173func 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).
185func (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: &notice}))
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.
195func signOutToSignedOut(signout http.HandlerFunc) http.HandlerFunc {
196 return func(w http.ResponseWriter, r *http.Request) {
197 signout(&signedOutRedirect{ResponseWriter: w}, r)
198 }
199}
200
201type signedOutRedirect struct{ http.ResponseWriter }
202
203func (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