| 1 | package main |
| 2 | |
| 3 | import ( |
| 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 |
| 17 | var 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. |
| 31 | var 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. |
| 40 | var pages = map[string]*template.Template{} |
| 41 | |
| 42 | func 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. |
| 54 | type 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. |
| 76 | func (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. |
| 100 | func (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. |
| 112 | func (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. |
| 119 | func (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. |
| 127 | type 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. |
| 141 | func (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. |
| 162 | func (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. |
| 180 | type signupView struct { |
| 181 | password.PageData |
| 182 | Invite string |
| 183 | } |
| 184 | |
| 185 | func (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 | |
| 189 | func (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 | |