Files
projax/web/server.go
mAi 840c1760c9 feat(auth): federate with mgmt.msbls.de via Supabase cookies
projax was deployed publicly through Dokploy/Traefik with a Let's
Encrypt cert; the earlier "Tailscale-only" claim was never true. Gate
every request at the application layer using the same Supabase JWT
cookie pair that mgmt.msbls.de issues, so projax inherits SSO without
running its own login.

Middleware (web/auth.go):
- GET <SUPABASE_URL>/auth/v1/user with the access_token cookie or a
  Bearer header. On 2xx → pass through.
- On expiry, swap the refresh_token via /auth/v1/token?grant_type=
  refresh_token and rotate both cookies (Domain=msbls.de, HttpOnly,
  Secure, SameSite=Lax, Path=/, Max-Age=1y). Cookie attributes match
  mgmt/auth.ts verbatim — refreshed sessions stay drop-in compatible
  with the rest of the .msbls.de fleet.
- Anything still invalid → 302 to <PROJAX_LOGIN_URL>?redirectTo=
  <original-absolute-url>. mgmt's safeRedirect() rejects absolute URLs
  and falls back to /, so after login the user lands on mgmt; manual
  click back to projax then succeeds with the fresh cookie. UX is
  rough but functional; broadening mgmt's safeRedirect is parked for a
  separate PR.
- /healthz remains ungated so Dokploy/Traefik probes don't hit the
  redirect.

main.go: enable the middleware only when SUPABASE_URL is set; require
SUPABASE_ANON_KEY when it is (refuse to start otherwise). New env
overrides: PROJAX_LOGIN_URL (default https://mgmt.msbls.de/login),
PROJAX_COOKIE_DOMAIN (default msbls.de). Local dev with no env stays
fully anonymous.

Tests (7 cases, no DB needed): stub Supabase via httptest covers
healthz-open, anonymous-redirect, bad-cookie-redirect, good-cookie
pass-through, Bearer-pass-through, stale-but-refreshable rotation
(verifies cookie Domain/HttpOnly/Secure/SameSite), final fail
redirect.

DB-backed integration tests now honour PROJAX_SKIP_MIGRATE=1 so they
don't deadlock against the live container's auto-migrate during a
deploy window.

README + dokploy.yaml: kill the Tailscale-only claim, document the
federated-auth trust model and the new SUPABASE_* env contract.
2026-05-15 14:58:43 +02:00

370 lines
9.6 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.
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
}
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 /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
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "layout", 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)
})
}