Files
projax/web/server.go
mAi 360060b152 feat(auth): rip federation, give projax its own /login
mgmt.msbls.de is being retired; depending on it for auth was the wrong
direction. Match the mBrian / flexsiebels pattern instead — same
Supabase backend, but every tool runs its own login page and scopes
cookies to its own host.

Routes
- GET  /login   render a sign-in form (mBrian dark visual). If the
                request already has a valid session, jump to a safe
                redirectTo (or /).
- POST /login   exchange email+password at /auth/v1/token?grant_type=
                password, set cookies, 302 → redirectTo or /. On
                Supabase 4xx, re-render the form with the error.
- POST /logout  clear both cookies (Max-Age=-1) + 302 → /login.

Cookies
- access_token + refresh_token only. No Domain attribute → scope is
  projax.msbls.de exclusively. HttpOnly, Secure, SameSite=Lax, Path=/,
  Max-Age=1y. Matches mBrian + flexsiebels per-host pattern.

Middleware
- /healthz, /login, /logout always pass through (otherwise infinite
  redirect on the probe / login page).
- On invalid/expired session → 302 /login?redirectTo=<safe-path>,
  RELATIVE to projax. No more cross-host bounce.
- Cookie refresh on expiry still rotates both cookies in place.
- Bearer header path kept for scripted clients.

safeRedirect
- Path-only. Rejects "", "//*", "https://*", "\*", control-char
  injection. Cross-host or scheme bounces fall back to "/". Tested
  against the obvious bypasses.

Cleanup
- Drop PROJAX_LOGIN_URL + PROJAX_COOKIE_DOMAIN env vars (unused now).
- main.go: log "auth: own-login enabled" with the supabase URL on
  startup; warn loudly when SUPABASE_URL is unset.
- README trust-model section rewritten: own login, per-host cookies,
  same backend.
- layout.tmpl gains a "sign out" form-button in the nav so the tree /
  detail / classify pages can log out without curl.

Tests (14, no DB needed): stub Supabase via httptest covers
healthz/login/logout exemption, anonymous→/login redirect, valid
cookie + Bearer pass-through, stale-refresh rotation with NO Domain
attribute, hard-fail redirect, GET form render with redirectTo carry,
already-signed-in short-circuit, POST success with correct cookies,
POST bad-creds error surface, redirectTo safety (path-only, no //,
no absolute URLs), logout cookie clearance.

Full suite (incl. DB-backed): 27/27 green with PROJAX_SKIP_MIGRATE=1.
2026-05-15 15:16:55 +02:00

385 lines
10 KiB
Go

package web
import (
"context"
"embed"
"errors"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"sort"
"strings"
"github.com/m/projax/store"
)
//go:embed templates/*.tmpl
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// Server bundles handlers, templates, and the store.
type Server struct {
Store *store.Store
pages map[string]*template.Template
Logger *slog.Logger
Auth *AuthConfig // nil → no auth (local dev / tests)
}
// New builds a Server. Each page is parsed alongside the layout into its own
// Template so per-page `define "content"` blocks don't shadow each other. The
// login page is intentionally NOT wrapped in the regular layout (chrome would
// imply you're already inside the app).
func New(s *store.Store, logger *slog.Logger) (*Server, error) {
if logger == nil {
logger = slog.Default()
}
funcs := template.FuncMap{
"deref": func(p *string) string {
if p == nil {
return ""
}
return *p
},
}
pages := map[string]*template.Template{}
for _, name := range []string{"tree", "detail", "new", "classify", "error"} {
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/"+name+".tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", name, err)
}
pages[name] = t
}
loginTmpl, err := template.New("login").Funcs(funcs).ParseFS(templatesFS, "templates/login.tmpl")
if err != nil {
return nil, fmt.Errorf("parse login: %w", err)
}
pages["login"] = loginTmpl
return &Server{Store: s, pages: pages, Logger: logger}, nil
}
// Routes wires every URL to a handler and returns the mux.
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.handleTree)
mux.HandleFunc("GET /i/", s.handleDetail)
mux.HandleFunc("POST /i/", s.handleDetailWrite)
mux.HandleFunc("GET /new", s.handleNewForm)
mux.HandleFunc("POST /new", s.handleNewSubmit)
mux.HandleFunc("GET /admin/classify", s.handleClassify)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := s.Store.Pool.Ping(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
fmt.Fprintln(w, "ok")
})
static, _ := fs.Sub(staticFS, "static")
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
var h http.Handler = mux
if s.Auth != nil {
h = authMiddleware(*s.Auth, s.Logger, h)
}
return logging(s.Logger, h)
}
// --- handlers ---
type treeNode struct {
Item *store.Item
Children []*treeNode
}
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
items, err := s.Store.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
areas, orphans, projaxN, maiN := buildForest(items)
s.render(w, "tree", map[string]any{
"Title": "tree",
"Areas": areas,
"Orphans": orphans,
"ProjaxCount": projaxN,
"MaiCount": maiN,
})
}
func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/i/")
if path == "" {
http.NotFound(w, r)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
parents, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, "detail", map[string]any{
"Title": it.Title,
"Item": it,
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
})
}
func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/i/")
if base, ok := strings.CutSuffix(path, "/promote"); ok {
s.handlePromote(w, r, base)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
if !it.Editable() {
http.Error(w, "read-only source — use /promote", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
var parentID *string
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
parentID = &v
}
in := store.UpdateInput{
Title: strings.TrimSpace(r.FormValue("title")),
Slug: strings.TrimSpace(r.FormValue("slug")),
ParentID: parentID,
ContentMD: r.FormValue("content_md"),
Status: strings.TrimSpace(r.FormValue("status")),
Pinned: r.FormValue("pinned") == "1",
Archived: r.FormValue("archived") == "1",
}
updated, err := s.Store.Update(r.Context(), it.ID, in)
if err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/i/"+updated.Path, http.StatusSeeOther)
}
func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request, path string) {
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
if it.Source != "mai.projects" || it.SourceRefID == nil {
http.Error(w, "promote: not a mai.projects row", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
parentID := strings.TrimSpace(r.FormValue("parent_id"))
slug := strings.TrimSpace(r.FormValue("slug"))
title := strings.TrimSpace(r.FormValue("title"))
if parentID == "" || slug == "" || title == "" {
http.Error(w, "promote: parent_id, slug, title required", http.StatusBadRequest)
return
}
newItem, err := s.Store.Promote(r.Context(), *it.SourceRefID, parentID, slug, title)
if err != nil {
s.fail(w, r, err)
return
}
// HTMX inline-promote on /admin/classify expects a fragment; full-page promote redirects.
if r.Header.Get("HX-Request") == "true" {
fmt.Fprintf(w, `<tr class="promoted"><td colspan="6">Promoted to <a href="/i/%s">%s</a></td></tr>`,
template.HTMLEscapeString(newItem.Path), template.HTMLEscapeString(newItem.Path))
return
}
http.Redirect(w, r, "/i/"+newItem.Path, http.StatusSeeOther)
}
func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
parentPath := r.URL.Query().Get("parent")
var parent *store.Item
if parentPath != "" {
p, err := s.Store.GetByPath(r.Context(), parentPath)
if err != nil {
s.fail(w, r, err)
return
}
if !p.Editable() {
http.Error(w, "parent must be a projax-native item", http.StatusBadRequest)
return
}
parent = p
}
s.render(w, "new", map[string]any{
"Title": "new",
"Parent": parent,
"StatusOptions": []string{"active", "done", "archived"},
})
}
func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
kind := strings.TrimSpace(r.FormValue("kind"))
if kind == "" {
kind = "project"
}
var parentID *string
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
parentID = &v
}
if kind == "project" && parentID == nil {
http.Error(w, "project requires a parent", http.StatusBadRequest)
return
}
in := store.CreateInput{
Kind: []string{kind},
Title: strings.TrimSpace(r.FormValue("title")),
Slug: strings.TrimSpace(r.FormValue("slug")),
ParentID: parentID,
ContentMD: r.FormValue("content_md"),
Status: strings.TrimSpace(r.FormValue("status")),
}
it, err := s.Store.Create(r.Context(), in)
if err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/i/"+it.Path, http.StatusSeeOther)
}
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
orphans, err := s.Store.MaiOrphans(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
parents, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, "classify", map[string]any{
"Title": "classify",
"Orphans": orphans,
"ParentOptions": parents,
})
}
// --- helpers ---
// ParentOption is a flat option for the parent <select>.
type ParentOption struct {
ID string
Path string
}
func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
items, err := s.Store.ListAll(ctx)
if err != nil {
return nil, err
}
var out []ParentOption
for _, it := range items {
if it.Source != "projax" {
continue
}
out = append(out, ParentOption{ID: it.ID, Path: it.Path})
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out, nil
}
func buildForest(items []*store.Item) (areas []*treeNode, orphans []*store.Item, projaxN, maiN int) {
byID := make(map[string]*treeNode, len(items))
for _, it := range items {
if it.Source == "projax" {
projaxN++
byID[it.ID] = &treeNode{Item: it}
} else {
maiN++
orphans = append(orphans, it)
}
}
for _, n := range byID {
if n.Item.ParentID == nil {
areas = append(areas, n)
continue
}
if parent, ok := byID[*n.Item.ParentID]; ok {
parent.Children = append(parent.Children, n)
}
}
sort.Slice(areas, func(i, j int) bool { return areas[i].Item.Slug < areas[j].Item.Slug })
for _, n := range byID {
sort.Slice(n.Children, func(i, j int) bool { return n.Children[i].Item.Slug < n.Children[j].Item.Slug })
}
return
}
func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) {
t, ok := s.pages[name]
if !ok {
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
return
}
entry := "layout"
if name == "login" {
// Login page is intentionally standalone — no nav chrome.
entry = "login"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, entry, data); err != nil {
s.Logger.Error("render", "page", name, "err", err)
}
}
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
status := http.StatusInternalServerError
if errors.Is(err, store.ErrNotFound) {
status = http.StatusNotFound
}
w.WriteHeader(status)
s.render(w, "error", map[string]any{
"Title": "error",
"Message": err.Error(),
})
s.Logger.Error("handler", "path", r.URL.Path, "err", err)
}
// logging wraps the mux with a tiny access log.
func logging(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
logger.Info("req", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
})
}