oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "net/http"
5 "strings"
6
7 "amadan.net/oficina/estilo.git/brand"
8 "amadan.net/oficina/estilo.git/chrome"
9 "amadan.net/oficina/estilo.git/sky"
10 "amadan.net/rastrillo/rastrillo/auth"
11 "amadan.net/rastrillo/rastrillo/sessions"
12)
13
14// The sign-in pages are estilo's door (estilo-signin, estilo-signin-sent,
15// estilo-signed-out) and so are their words: the form's heading, label,
16// help line and "Sign in" submit, the sent state that replaces the form,
17// and the signed-out page all come from chrome.English, so every app in
18// the suite says the same thing. Only the error lines are this app's.
19
20// signinError maps auth's redirect vocabulary to what the person in
21// front of the screen needs to do next. It SELECTS from a fixed set —
22// there is no path from the URL into the page.
23//
24// The default case is the point of this function. On the sign-in page
25// every outcome means one operational thing — we could not sign you in,
26// ask for a new link — so an unrecognised code must say that too, not
27// fall through to an app-wide "something went wrong". The codes that
28// actually reach it are ?err=1 (an internal failure in Begin) and any
29// future code auth adds.
30//
31// field is true for the one error that belongs in the address field's
32// own slot rather than a callout above the form (§9.2).
33//
34// An earlier version of this comment claimed a second click on an
35// expired link fell through to the default. It does not: auth redirects
36// ?err=expired from all three of its not-found paths, and rastrillo's own
37// auth_test.go asserts it for the second Verify.
38func signinError(code string) (text string, field bool) {
39 switch code {
40 case "":
41 return "", false
42 case "rate":
43 return "Too many attempts. Wait a few minutes, then try again.", false
44 case "address":
45 // rastrillo's ?err=address is an address it could not parse.
46 // A refused (unadmitted) address is never told so at the form:
47 // handleBegin answers it exactly as it answers an admitted one.
48 return "That doesn’t look like an email address.", true
49 case "expired":
50 return "That link has expired or was already used. Enter your email to get a new one.", false
51 case "keymail":
52 return "Your keymail sign-in couldn’t be completed. Enter your email to get a link instead.", false
53 default:
54 return "We couldn’t sign you in. Enter your email to get a new link.", false
55 }
56}
57
58// signinView is the sign-in page's own payload: the form, the sent
59// state that replaces it, or the confirm step an emailed link opens on.
60type signinView struct {
61 Form chrome.SignIn
62 Sent *chrome.SignInNotice
63 Confirm *signinConfirm
64}
65
66// signinFormPath is where the local sign-in form lives. With Home
67// configured, bare /signin starts the Home exchange and the local form is
68// /signin/local; standalone, /signin is the form.
69func (a *App) signinFormPath() string {
70 if a.home != nil {
71 return "/signin/local"
72 }
73 return "/signin"
74}
75
76// handleSignin serves the sign-in page.
77//
78// Standalone, somebody already signed in never sees the form (§9.2's
79// last row): /signin answers 303 to a safe return_to or the list. With
80// Home configured this handler is the LOCAL form behind /signin/local,
81// which a person with a Home session reaches on purpose to link an
82// existing account, so it never skips there; connect's own /signin does
83// the skip in that mode.
84func (a *App) handleSignin(w http.ResponseWriter, r *http.Request) {
85 q := r.URL.Query()
86 if a.home == nil && q.Get("sent") == "" && q.Get("err") == "" && q.Get("force") == "" {
87 if _, ok := a.identity(r); ok {
88 http.Redirect(w, r, signedInLanding(r), http.StatusSeeOther)
89 return
90 }
91 }
92 if q.Get("sent") != "" {
93 w.Header().Set("Cache-Control", "no-store")
94 // rastrillo's Begin redirects to ?sent=1 with no address, so the
95 // page cannot echo it (Docs made the same call).
96 sent := chrome.SignInNotice{Icon: brand.Path("sheets"), App: "Sheets", Sky: sky.For(r.Host)}.Sent(a.signinFormPath())
97 a.render(w, r, "signin", frame{parts: []string{sent.Page()}, signinPage: true}, http.StatusOK, signinView{Sent: &sent})
98 return
99 }
100 text, field := signinError(q.Get("err"))
101 a.renderDoor(w, r, http.StatusOK, text, field)
102}
103
104// renderDoor draws the sign-in form with text as its error, in the
105// address field's own slot when field is set. handleSignin passes the
106// query's error; App.refused passes the refusal with a 403.
107func (a *App) renderDoor(w http.ResponseWriter, r *http.Request, status int, text string, field bool) {
108 form := a.door(r)
109 form.Action = "/signin"
110 if field {
111 form.AddressError = text
112 } else {
113 form.Error = text
114 }
115 // Force is the affordance auth asks for. When a keymail sign-in
116 // fails, auth redirects to ?force=1&err=keymail specifically so this
117 // page can offer the plain-email path: Begin reads a non-empty
118 // "force" and sends a magic link instead of bouncing back to keymail.
119 // Without carrying it, a keymail user whose OAuth fails is redirected
120 // to keymail again on every retry, with no way out.
121 if r.URL.Query().Get("force") != "" {
122 form.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}}
123 }
124 a.render(w, r, "signin", frame{parts: []string{form.Page()}, signinPage: true}, status, signinView{Form: form})
125}
126
127// door is the sign-in door's chrome for this instance: Sheets' mark and
128// name on its sky.
129func (a *App) door(r *http.Request) chrome.SignIn {
130 return chrome.SignIn{
131 Icon: brand.Path("sheets"),
132 App: "Sheets",
133 Sky: sky.For(r.Host),
134 }
135}
136
137// renderConfirm is auth's RenderConfirm: the page an emailed link opens
138// on. GET /auth/verify spends nothing, so a mail-security scanner that
139// fetches every link in an inbound message cannot spend this one; the
140// door's single Sign in button POSTs the token back to d.Action, and
141// that POST signs in.
142//
143// It is the door with the address field taken away — the same mark,
144// sky, heading and Sign in label — so the person who clicked the link
145// sees Sheets, not rastrillo's plain English fallback. auth has already
146// set no-store, Referrer-Policy: strict-origin (the URL is a live
147// credential) and the framing refusals; render adds nothing that
148// replaces them. d.Token is untrusted query input and reaches the page
149// only through html/template.
150func (a *App) renderConfirm(w http.ResponseWriter, r *http.Request, d auth.ConfirmPageData) {
151 c := signinConfirm{Door: a.door(r), Action: d.Action, Token: d.Token}
152 c.Door.Lede = "Select Sign in to finish signing in."
153 a.render(w, r, "signin", frame{parts: []string{c.Door.Page()}, signinPage: true}, http.StatusOK, signinView{Confirm: &c})
154}
155
156// signinConfirm is the confirm step's door: the chrome, and the form
157// that posts the emailed token back.
158type signinConfirm struct {
159 Door chrome.SignIn
160 Action, Token string
161}
162
163// signedInLanding is where an already-signed-in /signin goes: the
164// request's return_to when rastrillo's SafeReturn accepts it and it is
165// not itself a sign-in page (which would loop), otherwise the list.
166func signedInLanding(r *http.Request) string {
167 target := sessions.SafeReturn(r, "/sheets")
168 if target == "/" || strings.HasPrefix(target, "/signin") || strings.HasPrefix(target, "/auth/") || strings.HasPrefix(target, "/signed-out") {
169 return "/sheets"
170 }
171 return target
172}
173
174// renderSignedOut is where Sign out lands: /signed-out, in both modes.
175// With Home configured connect's route renders it through ConfigureHome's
176// callback; standalone, signOutToPage sends Sign out here.
177func (a *App) renderSignedOut(w http.ResponseWriter, r *http.Request, status int) {
178 w.Header().Set("Cache-Control", "no-store")
179 out := chrome.SignInNotice{Icon: brand.Path("sheets"), App: "Sheets", Sky: sky.For(r.Host)}.SignedOut("/signin")
180 a.render(w, r, "signed_out", frame{parts: []string{out.Page()}, signinPage: true}, status, out)
181}
182
183func (a *App) handleSignedOut(w http.ResponseWriter, r *http.Request) {
184 a.renderSignedOut(w, r, http.StatusOK)
185}
186
187// signOutToPage is rastrillo's Sign out, landing on /signed-out instead of
188// the sign-in form. auth.Signout ends the session and 303s to its
189// SigninPath; this rewrites only that one Location, so everything else
190// auth answers (its cross-origin refusal) is untouched.
191func (a *App) signOutToPage(w http.ResponseWriter, r *http.Request) {
192 a.auth.Signout(&landing{ResponseWriter: w}, r)
193}
194
195type landing struct{ http.ResponseWriter }
196
197func (l *landing) WriteHeader(code int) {
198 if code == http.StatusSeeOther && l.Header().Get("Location") == "/signin" {
199 l.Header().Set("Location", "/signed-out")
200 }
201 l.ResponseWriter.WriteHeader(code)
202}
203