package httpx import ( "fmt" "html/template" "io/fs" "net/http" "net/url" "strings" "bountyboard/internal/domain" "bountyboard/web" ) // pageData is the payload every template receives. type pageData struct { Title string Active string // nav highlight key User *domain.User DefaultTheme string Narrow bool OIDCEnabled bool Error string HasLocalAuth bool Scripts []string Data map[string]any // page-specific extras } var templateFuncs = template.FuncMap{ "initials": func(name string) string { parts := strings.Fields(name) switch { case len(parts) >= 2: return strings.ToUpper(string([]rune(parts[0])[:1]) + string([]rune(parts[1])[:1])) case len(parts) == 1 && len(parts[0]) > 0: return strings.ToUpper(string([]rune(parts[0])[:1])) default: return "?" } }, } // parseTemplates builds one template set per page (layout + page content). func parseTemplates() (map[string]*template.Template, error) { pages, err := fs.Glob(web.FS, "templates/*.html") if err != nil { return nil, err } out := make(map[string]*template.Template) for _, page := range pages { name := strings.TrimPrefix(page, "templates/") if name == "layout.html" { continue } t, err := template.New(name).Funcs(templateFuncs). ParseFS(web.FS, "templates/layout.html", page) if err != nil { return nil, fmt.Errorf("parse %s: %w", page, err) } out[name] = t } return out, nil } func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data *pageData) { t, ok := s.templates[page] if !ok { s.internalError(w, r, "render", fmt.Errorf("unknown template %q", page)) return } if data == nil { data = &pageData{} } if data.DefaultTheme == "" { data.DefaultTheme = "light" if data.User != nil && data.User.Settings.Theme != "" { data.DefaultTheme = data.User.Settings.Theme } } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := t.ExecuteTemplate(w, "layout", data); err != nil { s.log.Error("execute template", "page", page, "err", err) } } // pageUser resolves the session for an HTML page; returns nil when not // logged in (no error response — pages redirect instead). func (s *Server) pageUser(r *http.Request) *domain.User { c, err := r.Cookie(sessionCookie) if err != nil || c.Value == "" { return nil } sess, err := s.store.SessionByToken(r.Context(), c.Value) if err != nil { return nil } u, err := s.store.UserByID(r.Context(), sess.UserID) if err != nil || u.Disabled { return nil } return u } // page wraps an HTML handler that requires login: redirects anonymous // visitors to /login?next=… and forces pending password changes through // /change-password. func (s *Server) page(h func(http.ResponseWriter, *http.Request, *domain.User)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u := s.pageUser(r) if u == nil { http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound) return } if u.MustChangePassword() && r.URL.Path != "/change-password" { http.Redirect(w, r, "/change-password", http.StatusFound) return } h(w, r, u) } } func (s *Server) routesWeb(mux *http.ServeMux) { staticFS, err := fs.Sub(web.FS, "static") if err != nil { panic(err) // embed layout is fixed at compile time } mux.Handle("GET /static/", http.StripPrefix("/static/", cacheControl(http.FileServerFS(staticFS), "public, max-age=3600"))) mux.HandleFunc("GET /{$}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { s.render(w, r, "home.html", &pageData{Title: "Home", User: u}) })) mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) { if s.pageUser(r) != nil { http.Redirect(w, r, "/", http.StatusFound) return } s.render(w, r, "login.html", &pageData{ Title: "Log in", Narrow: true, OIDCEnabled: s.cfg.OIDC.Enabled(), Error: loginErrorMessage(r.URL.Query().Get("error")), Scripts: []string{"/static/js/login.js"}, }) }) mux.HandleFunc("GET /register", func(w http.ResponseWriter, r *http.Request) { if s.pageUser(r) != nil { http.Redirect(w, r, "/", http.StatusFound) return } s.render(w, r, "register.html", &pageData{ Title: "Create account", Narrow: true, Scripts: []string{"/static/js/register.js"}, }) }) mux.HandleFunc("GET /change-password", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { s.render(w, r, "change-password.html", &pageData{ Title: "Change password", User: u, Narrow: true, Scripts: []string{"/static/js/change-password.js"}, }) })) mux.HandleFunc("GET /profile", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { s.render(w, r, "profile.html", &pageData{ Title: "Profile", User: u, HasLocalAuth: u.Auth.Local != nil, Scripts: []string{"/static/js/profile.js"}, }) })) mux.HandleFunc("GET /admin", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { if !u.Roles.Admin { s.render(w, r, "placeholder.html", &pageData{Title: "Not authorized", User: u}) return } s.render(w, r, "admin.html", &pageData{ Title: "Administration", Active: "admin", User: u, Scripts: []string{"/static/js/admin.js"}, }) })) // Placeholders for areas built in later phases; replaced as they land. placeholders := map[string]string{ "/board": "Bounty Board", "/my-tasks": "My Tasks", "/consultant/board": "Atomization Board", "/consultant/reviews": "Review Queue", "/consultant/pool": "Developer Pool", "/messages": "Messages", "/metrics": "Metrics", } for path, title := range placeholders { mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { s.render(w, r, "placeholder.html", &pageData{Title: title, User: u}) })) } } func loginErrorMessage(code string) string { switch code { case "": return "" case "oidc_state_mismatch", "oidc_failed": return "SSO sign-in failed. Please try again." case "oidc_denied": return "SSO sign-in was cancelled." case "oidc_email_unverified": return "Your SSO email address is not verified; ask your identity provider administrator." case "oidc_no_email": return "Your SSO identity has no email address; one is required." case "account_disabled": return "This account is disabled." default: return "Sign-in failed." } } func cacheControl(next http.Handler, value string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", value) next.ServeHTTP(w, r) }) }