feat: base UI shell with themes, auth pages, profile, and role navigation (phase 4)

- embedded Go templates + vanilla ES-module JS + hand-written CSS, no build step
- exact §10 beige light / dark design tokens, square edges (radius 2px),
  system font stack, visible focus rings
- theme toggle persisted to localStorage and the user profile
- login/register/change-password pages wired to the auth API
- profile page: avatar upload (image-sniffed, old file cleanup), bio,
  contacts, arbitrary extra key/value fields, optimistic-concurrency 409
- role-based top navigation with placeholders for later-phase areas
- GET /files/{id} with scope-based access (session) or signed token (§5.1)
- security headers incl. CSP without unsafe-inline scripts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 18:39:38 +02:00
parent c1cc781279
commit 34bf5b5ac2
27 changed files with 1579 additions and 5 deletions
+3 -1
View File
@@ -19,6 +19,7 @@ import (
"bountyboard/internal/auth"
"bountyboard/internal/config"
"bountyboard/internal/domain"
"bountyboard/internal/files"
"bountyboard/internal/metrics"
"bountyboard/internal/store"
"bountyboard/internal/testutil"
@@ -47,7 +48,8 @@ func newAuthStack(t *testing.T, extraEnv map[string]string) (*httptest.Server, *
if os.Getenv("TEST_LOG") == "1" {
logOut = os.Stderr
}
srv := New(cfg, slog.New(slog.NewTextHandler(logOut, nil)), metrics.NewRegistry(), st)
srv := New(cfg, slog.New(slog.NewTextHandler(logOut, nil)), metrics.NewRegistry(), st,
files.NewStore(db, cfg.MaxUploadMB))
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, srv, st, cfg
+17 -1
View File
@@ -57,7 +57,23 @@ func (r *statusRecorder) Write(b []byte) (int, error) {
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
func (s *Server) withMiddleware(next http.Handler) http.Handler {
return s.recoverPanic(s.assignRequestID(s.resolveClientInfo(s.accessLog(next))))
return s.recoverPanic(s.assignRequestID(s.resolveClientInfo(s.securityHeaders(s.accessLog(next)))))
}
// securityHeaders applies the §12 hardening headers. CSP allows no inline
// scripts — all JS ships as external modules.
func (s *Server) securityHeaders(next http.Handler) http.Handler {
const csp = "default-src 'self'; script-src 'self'; style-src 'self'; " +
"img-src 'self' data:; connect-src 'self'; font-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", csp)
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
// recoverPanic guarantees no panic escapes a request path: it logs the stack
+226
View File
@@ -0,0 +1,226 @@
package httpx
import (
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/domain"
"bountyboard/internal/files"
"bountyboard/internal/store"
)
func (s *Server) routesProfile(mux *http.ServeMux) {
mux.Handle("GET /api/v1/profile", s.requireAuth(http.HandlerFunc(s.handleGetProfile)))
mux.Handle("PATCH /api/v1/profile", s.authed(s.handlePatchProfile))
mux.Handle("POST /api/v1/profile/avatar", s.authed(s.handleAvatarUpload))
mux.HandleFunc("GET /files/{id}", s.handleGetFile)
}
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"user": CurrentUser(r.Context())})
}
// handlePatchProfile applies a partial profile update. Fields absent from
// the body are untouched. When "version" is supplied the update is
// optimistic-concurrency checked (409 on conflict); theme-only updates from
// the nav toggle omit it.
func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
var req struct {
Version *int64 `json:"version"`
Name *string `json:"name"`
Bio *string `json:"bio"`
Contact *domain.Contact `json:"contact"`
Extra *map[string]any `json:"extra"`
Settings *struct {
Theme *string `json:"theme"`
Notifications *domain.NotificationPrefs `json:"notifications"`
} `json:"settings"`
}
if !decodeJSON(w, r, &req) {
return
}
set := bson.M{}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "invalid_name", "name cannot be empty")
return
}
set["name"] = name
}
if req.Bio != nil {
set["bio"] = *req.Bio
}
if req.Contact != nil {
if req.Contact.Links == nil {
req.Contact.Links = []string{}
}
set["contact"] = *req.Contact
}
if req.Extra != nil {
set["extra"] = *req.Extra
}
if req.Settings != nil {
if req.Settings.Theme != nil {
theme := *req.Settings.Theme
if theme != "light" && theme != "dark" {
writeError(w, http.StatusBadRequest, "invalid_theme", "theme must be light or dark")
return
}
set["settings.theme"] = theme
}
if req.Settings.Notifications != nil {
set["settings.notifications"] = *req.Settings.Notifications
}
}
if len(set) == 0 {
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields to update")
return
}
u := CurrentUser(r.Context())
version := u.Version
if req.Version != nil {
version = *req.Version
}
err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("users"), u.ID, version, bson.M{"$set": set})
switch {
case errors.Is(err, store.ErrVersionConflict):
writeError(w, http.StatusConflict, "conflict", "profile was modified elsewhere; reload and retry")
return
case err != nil:
s.internalError(w, r, "update profile", err)
return
}
fresh, err := s.store.UserByID(r.Context(), u.ID)
if err != nil {
s.internalError(w, r, "reload profile", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": fresh})
}
func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
u := CurrentUser(r.Context())
r.Body = http.MaxBytesReader(w, r.Body, int64(s.cfg.MaxUploadMB)<<20+1<<20)
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "multipart field 'file' is required")
return
}
defer file.Close()
meta, err := s.files.Save(r.Context(), files.ScopeAvatar, u.ID, header.Filename,
header.Header.Get("Content-Type"), file)
if err != nil {
if errors.Is(err, files.ErrTooLarge) {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", err.Error())
return
}
s.internalError(w, r, "save avatar", err)
return
}
if !strings.HasPrefix(meta.MimeType, "image/") {
if delErr := s.files.Delete(r.Context(), meta.ID); delErr != nil {
s.log.Warn("delete rejected avatar", "err", delErr)
}
writeError(w, http.StatusBadRequest, "not_an_image", "avatar must be an image file")
return
}
old := u.AvatarFileID
err = store.UpdateVersioned(r.Context(), s.store.DB.Collection("users"), u.ID, u.Version,
bson.M{"$set": bson.M{"avatarFileId": meta.ID}})
if err != nil {
if delErr := s.files.Delete(r.Context(), meta.ID); delErr != nil {
s.log.Warn("delete orphaned avatar", "err", delErr)
}
if errors.Is(err, store.ErrVersionConflict) {
writeError(w, http.StatusConflict, "conflict", "profile was modified elsewhere; reload and retry")
return
}
s.internalError(w, r, "update avatar", err)
return
}
if old != "" {
if delErr := s.files.Delete(r.Context(), old); delErr != nil && !errors.Is(delErr, files.ErrNotFound) {
s.log.Warn("delete previous avatar", "err", delErr)
}
}
writeJSON(w, http.StatusOK, map[string]string{"fileId": meta.ID})
}
// handleGetFile serves stored files (§4.7): a valid session OR a valid
// signed token (?st=, §5.1) grants access. Scope rules: avatars are visible
// to any authenticated user; chat/task files additionally require ownership
// until their features land (tightened per-scope in later phases).
func (s *Server) handleGetFile(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
signedOK := false
if st := r.URL.Query().Get("st"); st != "" {
signedOK = files.VerifyToken([]byte(s.cfg.SessionSecret), id, st, time.Now())
}
var user *domain.User
if !signedOK {
user = s.pageUser(r)
if user == nil {
writeError(w, http.StatusUnauthorized, "unauthenticated", "session or signed token required")
return
}
}
rc, meta, err := s.files.Open(r.Context(), id)
if err != nil {
if errors.Is(err, files.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "file not found")
return
}
s.internalError(w, r, "open file", err)
return
}
defer rc.Close()
if !signedOK {
allowed := false
switch meta.Scope {
case files.ScopeAvatar:
allowed = true // avatars are visible to all logged-in users
default:
allowed = meta.OwnerID == user.ID || user.Roles.Admin ||
user.Roles.Consultant // scoped tighter as chat/task features land
}
if !allowed {
writeError(w, http.StatusForbidden, "forbidden", "no access to this file")
return
}
}
w.Header().Set("Content-Type", meta.MimeType)
w.Header().Set("X-Content-Type-Options", "nosniff")
if meta.SHA256 != "" {
etag := `"` + meta.SHA256 + `"`
w.Header().Set("ETag", etag)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
}
disposition := "attachment"
if strings.HasPrefix(meta.MimeType, "image/") || meta.MimeType == "application/pdf" ||
strings.HasPrefix(meta.MimeType, "text/") {
disposition = "inline"
}
w.Header().Set("Content-Disposition", disposition+`; filename="`+strings.ReplaceAll(meta.Name, `"`, "")+`"`)
w.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10))
if _, err := io.Copy(w, rc); err != nil {
s.log.Warn("stream file", "id", id, "err", err)
}
}
+286
View File
@@ -0,0 +1,286 @@
//go:build integration
package httpx
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"strings"
"testing"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/domain"
"bountyboard/internal/files"
)
// tiny valid 1x1 PNG
var pngBytes = []byte{
0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A,
0, 0, 0, 0x0D, 'I', 'H', 'D', 'R', 0, 0, 0, 1, 0, 0, 0, 1,
8, 6, 0, 0, 0, 0x1F, 0x15, 0xC4, 0x89,
0, 0, 0, 0x0A, 'I', 'D', 'A', 'T', 0x78, 0x9C, 0x63, 0, 1, 0, 0, 5, 0, 1,
0x0D, 0x0A, 0x2D, 0xB4, 0, 0, 0, 0, 'I', 'E', 'N', 'D', 0xAE, 0x42, 0x60, 0x82,
}
func registerUser(t *testing.T, ts string, c *http.Client, email, name string) domain.User {
t.Helper()
resp := postJSON(t, c, ts+"/api/v1/auth/register",
map[string]string{"email": email, "name": name, "password": "password123"}, "")
if resp.StatusCode != http.StatusCreated {
t.Fatalf("register %s: %d", email, resp.StatusCode)
}
var out struct {
User domain.User `json:"user"`
}
bodyJSON(t, resp, &out)
return out.User
}
func patchJSON(t *testing.T, c *http.Client, rawURL string, body any, csrf string) *http.Response {
t.Helper()
b, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPatch, rawURL, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(csrfHeader, csrf)
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func TestProfilePatchAndThemePersistence(t *testing.T) {
ts, _, _, _ := newAuthStack(t, nil)
c := newClient(t)
u := registerUser(t, ts.URL, c, "p@example.com", "P User")
csrf := csrfFrom(t, c, ts.URL)
resp := patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{
"version": u.Version,
"name": "Updated Name",
"bio": "I write Go.",
"contact": map[string]any{"phone": "+1 555 0100", "location": "Berlin", "links": []string{"https://example.com"}},
"extra": map[string]any{"GitHub": "puser", "Timezone": "CET"},
"settings": map[string]any{
"theme": "dark",
},
}, csrf)
if resp.StatusCode != http.StatusOK {
t.Fatalf("patch: %d", resp.StatusCode)
}
var out struct {
User domain.User `json:"user"`
}
bodyJSON(t, resp, &out)
if out.User.Name != "Updated Name" || out.User.Settings.Theme != "dark" ||
out.User.Contact.Location != "Berlin" || out.User.Extra["GitHub"] != "puser" {
t.Fatalf("patched user wrong: %+v", out.User)
}
if out.User.Version != u.Version+1 {
t.Errorf("version = %d, want %d", out.User.Version, u.Version+1)
}
// stale version → 409
resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{
"version": u.Version, "name": "Stale Write",
}, csrf)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("stale patch: %d, want 409", resp.StatusCode)
}
resp.Body.Close()
// theme-only update without version (nav toggle path)
resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{
"settings": map[string]any{"theme": "light"},
}, csrf)
if resp.StatusCode != http.StatusOK {
t.Fatalf("theme toggle patch: %d", resp.StatusCode)
}
bodyJSON(t, resp, &out)
if out.User.Settings.Theme != "light" {
t.Errorf("theme = %q", out.User.Settings.Theme)
}
// invalid theme rejected
resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{
"settings": map[string]any{"theme": "neon"},
}, csrf)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid theme: %d", resp.StatusCode)
}
resp.Body.Close()
}
func uploadAvatar(t *testing.T, c *http.Client, ts, csrf, filename string, content []byte) *http.Response {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write(content); err != nil {
t.Fatal(err)
}
mw.Close()
req, _ := http.NewRequest(http.MethodPost, ts+"/api/v1/profile/avatar", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set(csrfHeader, csrf)
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func TestAvatarUploadAndFileServing(t *testing.T) {
ts, _, _, cfg := newAuthStack(t, nil)
c := newClient(t)
registerUser(t, ts.URL, c, "av@example.com", "Av User")
csrf := csrfFrom(t, c, ts.URL)
// non-image rejected
resp := uploadAvatar(t, c, ts.URL, csrf, "notes.txt", []byte("just text, definitely not an image"))
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("text avatar: %d, want 400", resp.StatusCode)
}
resp.Body.Close()
// PNG accepted
resp = uploadAvatar(t, c, ts.URL, csrf, "me.png", pngBytes)
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("png avatar: %d %s", resp.StatusCode, b)
}
var up struct {
FileID string `json:"fileId"`
}
bodyJSON(t, resp, &up)
// served with session
fResp, err := c.Get(ts.URL + "/files/" + up.FileID)
if err != nil {
t.Fatal(err)
}
got, _ := io.ReadAll(fResp.Body)
fResp.Body.Close()
if fResp.StatusCode != http.StatusOK || !bytes.Equal(got, pngBytes) {
t.Fatalf("file fetch: %d, %d bytes", fResp.StatusCode, len(got))
}
if ct := fResp.Header.Get("Content-Type"); ct != "image/png" {
t.Errorf("content type %q", ct)
}
// anonymous fetch rejected
aResp, err := http.Get(ts.URL + "/files/" + up.FileID)
if err != nil {
t.Fatal(err)
}
aResp.Body.Close()
if aResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anonymous file fetch: %d, want 401", aResp.StatusCode)
}
// signed token works without a session and expires (§5.1 acceptance)
goodURL := files.SignedURL(ts.URL, []byte(cfg.SessionSecret), up.FileID, time.Now().Add(time.Hour))
gResp, err := http.Get(goodURL)
if err != nil {
t.Fatal(err)
}
gResp.Body.Close()
if gResp.StatusCode != http.StatusOK {
t.Fatalf("signed fetch: %d", gResp.StatusCode)
}
expiredURL := files.SignedURL(ts.URL, []byte(cfg.SessionSecret), up.FileID, time.Now().Add(-time.Minute))
eResp, err := http.Get(expiredURL)
if err != nil {
t.Fatal(err)
}
eResp.Body.Close()
if eResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expired signed fetch: %d, want 401", eResp.StatusCode)
}
// replacing the avatar deletes the old file
resp = uploadAvatar(t, c, ts.URL, csrf, "me2.png", pngBytes)
var up2 struct {
FileID string `json:"fileId"`
}
bodyJSON(t, resp, &up2)
oldResp, err := c.Get(ts.URL + "/files/" + up.FileID)
if err != nil {
t.Fatal(err)
}
oldResp.Body.Close()
if oldResp.StatusCode != http.StatusNotFound {
t.Fatalf("old avatar after replace: %d, want 404", oldResp.StatusCode)
}
}
func TestNavigationByRole(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
c := newClient(t)
u := registerUser(t, ts.URL, c, "nav@example.com", "Nav User")
fetchHome := func() string {
resp, err := c.Get(ts.URL + "/")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("home: %d", resp.StatusCode)
}
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// developer: board links, no admin/consultant links
html := fetchHome()
if !strings.Contains(html, `href="/board"`) {
t.Error("developer nav missing bounty board")
}
for _, forbidden := range []string{`href="/admin"`, `href="/consultant/board"`} {
if strings.Contains(html, forbidden) {
t.Errorf("developer nav leaks %s", forbidden)
}
}
// grant consultant+admin directly in the store
if _, err := st.DB.Collection("users").UpdateOne(t.Context(),
bson.M{"_id": u.ID},
bson.M{"$set": bson.M{"roles.admin": true, "roles.consultant": true}}); err != nil {
t.Fatal(err)
}
html = fetchHome()
for _, want := range []string{`href="/admin"`, `href="/consultant/board"`, `href="/consultant/reviews"`} {
if !strings.Contains(html, want) {
t.Errorf("admin+consultant nav missing %s", want)
}
}
}
func TestForcedPasswordChangeRedirectsPages(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
c := newClient(t)
u := registerUser(t, ts.URL, c, "fc@example.com", "FC")
if _, err := st.DB.Collection("users").UpdateOne(t.Context(),
bson.M{"_id": u.ID}, bson.M{"$set": bson.M{"auth.local.mustChange": true}}); err != nil {
t.Fatal(err)
}
resp, err := c.Get(ts.URL + "/profile")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/change-password" {
t.Fatalf("page under mustChange: %d → %q", resp.StatusCode, resp.Header.Get("Location"))
}
}
+15 -1
View File
@@ -4,12 +4,14 @@ import (
"context"
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
"time"
"bountyboard/internal/auth"
"bountyboard/internal/config"
"bountyboard/internal/files"
"bountyboard/internal/metrics"
"bountyboard/internal/store"
)
@@ -22,6 +24,8 @@ type Server struct {
log *slog.Logger
metrics *metrics.Registry
store *store.Store
files *files.Store
templates map[string]*template.Template
oidc *auth.OIDCClient
loginLimiter *auth.RateLimiter
checks []ReadinessCheck
@@ -29,12 +33,20 @@ type Server struct {
httpSrv *http.Server
}
func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store) *Server {
func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store, fs *files.Store) *Server {
templates, err := parseTemplates()
if err != nil {
// Templates are embedded; failure is a build defect caught by any
// test or first boot, never a runtime condition.
panic(fmt.Sprintf("parse templates: %v", err))
}
s := &Server{
cfg: cfg,
log: log,
metrics: reg,
store: st,
files: fs,
templates: templates,
oidc: auth.NewOIDCClient(cfg, log),
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
startedAt: time.Now(),
@@ -45,6 +57,8 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
mux.HandleFunc("GET /readyz", s.handleReadyz)
mux.HandleFunc("GET /metricsz", s.handleMetricsz)
s.routesAuth(mux)
s.routesProfile(mux)
s.routesWeb(mux)
s.httpSrv = &http.Server{
Addr: fmt.Sprintf(":%d", cfg.AppPort),
+1 -1
View File
@@ -25,7 +25,7 @@ func newTestServer(t *testing.T) *Server {
t.Fatalf("config: %v", err)
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
return New(cfg, log, metrics.NewRegistry(), nil)
return New(cfg, log, metrics.NewRegistry(), nil, nil)
}
func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
+215
View File
@@ -0,0 +1,215 @@
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"},
})
}))
// 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",
"/admin": "Administration",
"/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)
})
}
+109
View File
@@ -0,0 +1,109 @@
package httpx
import (
"net/http"
"strings"
"testing"
)
func TestLoginPageRenders(t *testing.T) {
s := newTestServer(t)
rec := get(t, s.Handler(), "/login")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"login-form", `id="email"`, `id="password"`, "/static/js/login.js"} {
if !strings.Contains(body, want) {
t.Errorf("login page missing %q", want)
}
}
// OIDC is not configured → no SSO button
if strings.Contains(body, "Continue with SSO") {
t.Error("SSO button shown although OIDC is disabled")
}
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "script-src 'self'") {
t.Errorf("CSP missing or weak: %q", csp)
}
if rec.Header().Get("X-Frame-Options") != "DENY" {
t.Error("X-Frame-Options missing")
}
}
func TestRegisterPageRenders(t *testing.T) {
s := newTestServer(t)
rec := get(t, s.Handler(), "/register")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "register-form") {
t.Error("register form missing")
}
}
func TestAnonymousPageRedirectsToLogin(t *testing.T) {
s := newTestServer(t)
for _, path := range []string{"/", "/profile", "/board", "/admin", "/messages"} {
rec := get(t, s.Handler(), path)
if rec.Code != http.StatusFound {
t.Errorf("%s: status = %d, want 302", path, rec.Code)
continue
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "/login?next=") {
t.Errorf("%s: redirect to %q", path, loc)
}
}
}
func TestStaticAssetsServed(t *testing.T) {
s := newTestServer(t)
tests := map[string]string{
"/static/css/app.css": "--bg:#f3ead9", // beige light token from §10
"/static/js/theme.js": "data-default-theme",
"/static/js/api.js": "X-CSRF-Token",
"/static/js/login.js": "auth/login",
"/static/js/nav.js": "nav-theme",
}
for path, want := range tests {
rec := get(t, s.Handler(), path)
if rec.Code != http.StatusOK {
t.Errorf("%s: status = %d", path, rec.Code)
continue
}
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("%s: missing %q", path, want)
}
}
}
func TestThemeTokensPresent(t *testing.T) {
s := newTestServer(t)
rec := get(t, s.Handler(), "/static/css/app.css")
css := rec.Body.String()
// spot-check both §10 palettes and the square-edge radius
for _, tok := range []string{
"--bg:#f3ead9", "--accent:#8a5a2b", // light
"--bg:#191714", "--accent:#caa15e", // dark
"--radius:2px",
} {
if !strings.Contains(css, tok) {
t.Errorf("css missing design token %q", tok)
}
}
}
func TestInitialsFunc(t *testing.T) {
tests := []struct{ in, want string }{
{"Jane Doe", "JD"},
{"single", "S"},
{"", "?"},
{" spaced out ", "SO"},
}
f := templateFuncs["initials"].(func(string) string)
for _, tt := range tests {
if got := f(tt.in); got != tt.want {
t.Errorf("initials(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}