Step 2 of the build order. The admin mints an invite link, the recipient registers with it, and from then on has a session. - The invite is spent in the same transaction that creates the account, so a failed signup leaves the code usable - Sessions are idle timeouts, 24h or 30 days with remember me, read from a cookie or a bearer header, extended at most once a minute - Ban is a reversible toggle that drops the member's live sessions - No password minimum; login is rate limited instead, 10 failures per email in 15 minutes, cleared by a correct password - Invite codes render as links carrying ?code=, which the register form prefills; PUBLIC_URL makes them pasteable from the loopback admin panel Tests cover invite spending, the idle timeout, ban, and the rate limiter.
93 lines
2.3 KiB
Go
93 lines
2.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
//go:embed templates static
|
|
var assetFS embed.FS
|
|
|
|
var funcs = template.FuncMap{
|
|
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
|
|
}
|
|
|
|
// Each page is parsed with the layout into its own set, so two pages may both define "content".
|
|
var pages = map[string]*template.Template{}
|
|
|
|
func init() {
|
|
entries, err := assetFS.ReadDir("templates")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
for _, e := range entries {
|
|
if e.Name() == "layout.html" {
|
|
continue
|
|
}
|
|
pages[e.Name()] = template.Must(template.New("layout.html").Funcs(funcs).
|
|
ParseFS(assetFS, "templates/layout.html", "templates/"+e.Name()))
|
|
}
|
|
}
|
|
|
|
// page is everything the layout needs, plus whatever the page itself wants in Data.
|
|
type page struct {
|
|
Title string
|
|
Member *member
|
|
Admin bool
|
|
Flash string
|
|
Path string
|
|
Data any
|
|
}
|
|
|
|
func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, p page) {
|
|
t, ok := pages[name]
|
|
if !ok {
|
|
slog.Error("unknown template", "name", name)
|
|
http.Error(w, "template", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
p.Member = memberFrom(r.Context())
|
|
p.Path = r.URL.Path
|
|
p.Flash = a.takeFlash(w, r)
|
|
|
|
// Render to memory first: a template that fails halfway must not leave a half-written 200.
|
|
var buf bytes.Buffer
|
|
if err := t.ExecuteTemplate(&buf, "layout.html", p); err != nil {
|
|
slog.Error("render", "name", name, "error", err)
|
|
http.Error(w, "template", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
buf.WriteTo(w)
|
|
}
|
|
|
|
// Toasts are a cookie rendered server-side and cleared on read — no JS, no session storage.
|
|
func (a *app) flash(w http.ResponseWriter, msg string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "flash", Value: url.QueryEscape(msg), Path: "/",
|
|
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func (a *app) takeFlash(w http.ResponseWriter, r *http.Request) string {
|
|
c, err := r.Cookie("flash")
|
|
if err != nil || c.Value == "" {
|
|
return ""
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "flash", Value: "", Path: "/", MaxAge: -1,
|
|
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
msg, err := url.QueryUnescape(c.Value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return msg
|
|
}
|