rastrillo / idear Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "embed"
6 "html/template"
7 "net/http"
8
9 "amadan.net/rastrillo/rastrillo/flash"
10 "amadan.net/rastrillo/rastrillo/password"
11 "amadan.net/rastrillo/rastrillo/sessions"
12
13 "amadan.net/rastrillo/idear"
14)
15
16//go:embed pages
17var pagesFS embed.FS
18
19// funcs are the template helpers.
20//
21// mayActOn is idear.MayActOn with the error dropped — the SAME pure
22// predicate the store enforces inside its own transactions, asked
23// again so the page does not offer a control that would 403 on submit.
24// It takes the target by value because a range variable in a template
25// is a value and cannot be addressed.
26//
27// Asking the real predicate rather than writing "is the viewer an
28// admin" here is the point: the rule (nobody acts on an Owner, nobody
29// acts on an equal rank unless built with PeerGrants, nobody acts on
30// themselves) lives in one place, and a template that restated it
31// would drift from it.
32var funcs = template.FuncMap{
33 "mayActOn": func(actor *idear.Member, target idear.Member) bool {
34 return idear.MayActOn(actor, &target) == nil
35 },
36}
37
38// pages is one template per page, each parsed together with the
39// layout, so every page can define "content" without the last one
40// silently winning.
41var pages = map[string]*template.Template{}
42
43func init() {
44 for _, name := range []string{"board", "members", "invitation", "signin", "signup", "notfound", "forbidden"} {
45 pages[name] = template.Must(template.New("layout").Funcs(funcs).
46 ParseFS(pagesFS, "pages/layout.html", "pages/"+name+".html"))
47 }
48}
49
50// view is what every template renders against.
51//
52// Viewer is idear's Member, not the session: it is nil on the public
53// pages and past Require it is the roster row, so the layout's nav can
54// show a rank without a second lookup.
55type view struct {
56 Site string
57 SignedIn bool
58 Viewer *idear.Member
59 Flash flash.Flash
60 HasFlash bool
61 Content any
62}
63
64// execute renders name into a buffer and only then touches the wire.
65//
66// The buffer earns its keep twice: a template error becomes a clean
67// 500 instead of garbage appended to a half-written page, and any
68// Set-Cookie a caller added lands before the status line, since
69// headers set after WriteHeader are silently dropped.
70//
71// status 0 means "write no status" — which is what every renderer
72// idear or password calls must do, because BOTH of them write the
73// status themselves before calling out (idear's refusals are 400/403/
74// 404/500; password's are 422/403/429). A renderer that wrote its own
75// would lose to the first WriteHeader and log a duplicate-header
76// warning for its trouble.
77func (a *app) execute(w http.ResponseWriter, r *http.Request, status int, name string, fl flash.Flash, hasFlash bool, content any) {
78 _, signedIn := sessions.Current(r)
79 d := view{
80 Site: a.site,
81 SignedIn: signedIn,
82 Viewer: idear.From(r),
83 Flash: fl,
84 HasFlash: hasFlash,
85 Content: content,
86 }
87 var buf bytes.Buffer
88 if err := pages[name].ExecuteTemplate(&buf, "layout", d); err != nil {
89 a.logger.Error("render", "page", name, "err", err)
90 http.Error(w, "something went wrong", http.StatusInternalServerError)
91 return
92 }
93 if status != 0 {
94 w.WriteHeader(status)
95 }
96 buf.WriteTo(w)
97}
98
99// render is execute for the app's OWN pages: it takes the flash, and
100// it may write a status because nothing upstream has.
101func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, content any) {
102 fl, ok := flash.Take(w, r)
103 a.execute(w, r, status, name, fl, ok, content)
104}
105
106// renderNotFound is the app's 404 page — and idear's, and chi's. One
107// function, three callers, deliberately: see app.go.
108//
109// It takes no flash. Not-found is the answer a non-member gets on
110// every idear route, and the fewer inputs its body has the easier it
111// is to keep byte-identical to the 404 for a path that simply does not
112// exist.
113func (a *app) renderNotFound(w http.ResponseWriter, r *http.Request) {
114 a.execute(w, r, http.StatusNotFound, "notfound", flash.Flash{}, false, nil)
115}
116
117// renderForbidden is idear.Config.Forbidden: a member who may see a
118// page but may not act on it. 403, not 404 — they already know the
119// route exists.
120func (a *app) renderForbidden(w http.ResponseWriter, r *http.Request) {
121 a.execute(w, r, http.StatusForbidden, "forbidden", flash.Flash{}, false, nil)
122}
123
124// membersView is idear.MembersPage plus the one thing the template
125// cannot work out for itself: whether the viewer may transfer
126// ownership. Embedding keeps Viewer, Members, Invitations and
127// Grantable reachable as .Content.Members and so on.
128type membersView struct {
129 idear.MembersPage
130 IsOwner bool
131}
132
133// renderMembers is idear.HandlerConfig.RenderMembers.
134//
135// It writes NO status: idear writes 400/403/404/500 before calling
136// this on a refusal, and 200 by omission on success.
137//
138// It also does not call flash.Take. idear took the flash itself, in
139// its Members handler, and handed it over as Notice or Error — a
140// second Take in the same request would read the same cookie again and
141// show the notice twice.
142func (a *app) renderMembers(w http.ResponseWriter, r *http.Request, d idear.MembersPage) {
143 fl, has := flash.Flash{}, false
144 switch {
145 case d.Error != "":
146 fl, has = flash.Flash{Kind: "error", Message: d.Error}, true
147 case d.Notice != "":
148 fl, has = flash.Flash{Kind: "notice", Message: d.Notice}, true
149 }
150 a.execute(w, r, 0, "members", fl, has, membersView{
151 MembersPage: d,
152 IsOwner: d.Viewer != nil && d.Viewer.Role == idear.RoleOwner,
153 })
154}
155
156// renderInvitation is idear.HandlerConfig.RenderInvitation — the
157// public invitation page.
158//
159// idear hands it a Role, a Site, a Token and two booleans, and NO
160// address: the page must not echo who was invited, so there is no
161// field for it to echo. Writes no status; idear wrote 404 or 403 or
162// 500 already where one was due.
163func (a *app) renderInvitation(w http.ResponseWriter, r *http.Request, d idear.InvitationPage) {
164 a.execute(w, r, 0, "invitation", flash.Flash{}, false, d)
165}
166
167// signupView is password.PageData plus the invitation token.
168//
169// password.PageData carries Error, Email and ReturnTo and has nowhere
170// to put a token, so a signup that fails validation — a password under
171// eight characters, say — re-renders a form whose hidden "invite"
172// field would come back empty, and the invitee's SECOND attempt would
173// be refused for having no token. The symptom is "invited people can
174// never join", and it only appears on the second try.
175//
176// idear.TokenFrom(r) is what keeps that attempt working: it hands back
177// what CarryToken already lifted off this very POST.
178// TestInvitedSignupSurvivesAValidationFailure is what notices if this
179// is ever dropped — which is the likeliest thing to happen to anyone
180// who rewrites this page.
181type signupView struct {
182 password.PageData
183 Invite string
184}
185
186func (a *app) renderSignin(w http.ResponseWriter, r *http.Request, d password.PageData) {
187 a.execute(w, r, 0, "signin", flash.Flash{}, false, d)
188}
189
190func (a *app) renderSignup(w http.ResponseWriter, r *http.Request, d password.PageData) {
191 a.execute(w, r, 0, "signup", flash.Flash{}, false,
192 signupView{PageData: d, Invite: idear.TokenFrom(r)})
193}
194