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