package main import ( "bytes" "embed" "html/template" "net/http" "github.com/carlosframework/rastrillo/flash" "github.com/carlosframework/rastrillo/password" "github.com/carlosframework/rastrillo/sessions" "amadan.net/rastrillo/idear" ) //go:embed pages var pagesFS embed.FS // funcs are the template helpers. // // mayActOn is idear.MayActOn with the error dropped — the SAME pure // predicate the store enforces inside its own transactions, asked // again so the page does not offer a control that would 403 on submit. // It takes the target by value because a range variable in a template // is a value and cannot be addressed. // // Asking the real predicate rather than writing "is the viewer an // admin" here is the point: the rule (nobody acts on an Owner, nobody // acts on an equal rank, nobody acts on themselves) lives in one // place, and a template that restated it would drift from it. var funcs = template.FuncMap{ "mayActOn": func(actor *idear.Member, target idear.Member) bool { return idear.MayActOn(actor, &target) == nil }, } // pages is one template per page, each parsed together with the // layout, so every page can define "content" without the last one // silently winning. var pages = map[string]*template.Template{} func init() { for _, name := range []string{"board", "members", "invitation", "signin", "signup", "notfound", "forbidden"} { pages[name] = template.Must(template.New("layout").Funcs(funcs). ParseFS(pagesFS, "pages/layout.html", "pages/"+name+".html")) } } // view is what every template renders against. // // Viewer is idear's Member, not the session: it is nil on the public // pages and past Require it is the roster row, so the layout's nav can // show a rank without a second lookup. type view struct { Site string SignedIn bool Viewer *idear.Member Flash flash.Flash HasFlash bool Content any } // execute renders name into a buffer and only then touches the wire. // // The buffer earns its keep twice: a template error becomes a clean // 500 instead of garbage appended to a half-written page, and any // Set-Cookie a caller added lands before the status line, since // headers set after WriteHeader are silently dropped. // // status 0 means "write no status" — which is what every renderer // idear or password calls must do, because BOTH of them write the // status themselves before calling out (idear's refusals are 400/403/ // 404/500; password's are 422/403/429). A renderer that wrote its own // would lose to the first WriteHeader and log a duplicate-header // warning for its trouble. func (a *app) execute(w http.ResponseWriter, r *http.Request, status int, name string, fl flash.Flash, hasFlash bool, content any) { _, signedIn := sessions.Current(r) d := view{ Site: a.site, SignedIn: signedIn, Viewer: idear.From(r), Flash: fl, HasFlash: hasFlash, Content: content, } var buf bytes.Buffer if err := pages[name].ExecuteTemplate(&buf, "layout", d); err != nil { a.logger.Error("render", "page", name, "err", err) http.Error(w, "something went wrong", http.StatusInternalServerError) return } if status != 0 { w.WriteHeader(status) } buf.WriteTo(w) } // render is execute for the app's OWN pages: it takes the flash, and // it may write a status because nothing upstream has. func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, content any) { fl, ok := flash.Take(w, r) a.execute(w, r, status, name, fl, ok, content) } // renderNotFound is the app's 404 page — and idear's, and chi's. One // function, three callers, deliberately: see app.go. // // It takes no flash. Not-found is the answer a non-member gets on // every idear route, and the fewer inputs its body has the easier it // is to keep byte-identical to the 404 for a path that simply does not // exist. func (a *app) renderNotFound(w http.ResponseWriter, r *http.Request) { a.execute(w, r, http.StatusNotFound, "notfound", flash.Flash{}, false, nil) } // renderForbidden is idear.Config.Forbidden: a member who may see a // page but may not act on it. 403, not 404 — they already know the // route exists. func (a *app) renderForbidden(w http.ResponseWriter, r *http.Request) { a.execute(w, r, http.StatusForbidden, "forbidden", flash.Flash{}, false, nil) } // membersView is idear.MembersPage plus the one thing the template // cannot work out for itself: whether the viewer may transfer // ownership. Embedding keeps Viewer, Members, Invitations and // Grantable reachable as .Content.Members and so on. type membersView struct { idear.MembersPage IsOwner bool } // renderMembers is idear.HandlerConfig.RenderMembers. // // It writes NO status: idear writes 400/403/404/500 before calling // this on a refusal, and 200 by omission on success. // // It also does not call flash.Take. idear took the flash itself, in // its Members handler, and handed it over as Notice or Error — a // second Take in the same request would read the same cookie again and // show the notice twice. func (a *app) renderMembers(w http.ResponseWriter, r *http.Request, d idear.MembersPage) { fl, has := flash.Flash{}, false switch { case d.Error != "": fl, has = flash.Flash{Kind: "error", Message: d.Error}, true case d.Notice != "": fl, has = flash.Flash{Kind: "notice", Message: d.Notice}, true } a.execute(w, r, 0, "members", fl, has, membersView{ MembersPage: d, IsOwner: d.Viewer != nil && d.Viewer.Role == idear.RoleOwner, }) } // renderInvitation is idear.HandlerConfig.RenderInvitation — the // public invitation page. // // idear hands it a Role, a Site, a Token and two booleans, and NO // address: the page must not echo who was invited, so there is no // field for it to echo. Writes no status; idear wrote 404 or 403 or // 500 already where one was due. func (a *app) renderInvitation(w http.ResponseWriter, r *http.Request, d idear.InvitationPage) { a.execute(w, r, 0, "invitation", flash.Flash{}, false, d) } // signupView is password.PageData plus the invitation token. // // password.PageData carries Error, Email and ReturnTo and has nowhere // to put a token, so a signup that fails validation — a password under // eight characters, say — re-renders a form whose hidden "invite" // field would come back empty, and the invitee's SECOND attempt would // be refused for having no token. The symptom is "invited people can // never join", and it only appears on the second try. // // idear.TokenFrom(r) is what keeps that attempt working: it hands back // what CarryToken already lifted off this very POST. // TestInvitedSignupSurvivesAValidationFailure is what notices if this // is ever dropped — which is the likeliest thing to happen to anyone // who rewrites this page. type signupView struct { password.PageData Invite string } func (a *app) renderSignin(w http.ResponseWriter, r *http.Request, d password.PageData) { a.execute(w, r, 0, "signin", flash.Flash{}, false, d) } func (a *app) renderSignup(w http.ResponseWriter, r *http.Request, d password.PageData) { a.execute(w, r, 0, "signup", flash.Flash{}, false, signupView{PageData: d, Invite: idear.TokenFrom(r)}) }