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)})
}