Files
projax/web/server.go
mAi dc50823860 feat(phase 3a mcp): MCP surface so mai/otto/Claude can read+write projax
mcp package (new): minimal JSON-RPC 2.0 + MCP-protocol server, tools
delegate to *store.Store (no business-logic duplication).

- handler.go: handleRPC routes initialize / tools/list / tools/call /
  ping / notifications/initialized; Bearer-token middleware; results
  flow through the standard MCP content[].text envelope; tool errors
  surface as isError: true (transport errors stay JSON-RPC errors).
- tools.go: 10 tools — list_items / get_item / create_item /
  update_item / delete_item / list_links / add_link / remove_link /
  search / tree. Multi-parent in/out — parent_paths[] string array,
  resolved per call. itemView/linkView keep the wire shape snake_case
  and stable.
- mcp_test.go + tools_test.go: protocol primitives (no DB) plus a
  full create → get → search → delete round-trip skipping cleanly
  when the DB env is absent. Multi-parent assertion discovers the
  test pair from the live DB rather than hard-coding a row.

store extensions:
- ListByFilters(SearchFilters) with parent_path/tags/management/kind/
  status/q/has_repo/has_caldav predicates.
- Search(q, limit) ranked across title/slug/aliases/content_md.
- GetByPathOrSlug for callers that don't know the full path.
- SoftDeleteCascade refuses on live descendants unless cascade=true.

web:
- New optional Server.MCP http.Handler. main.go mounts an mcp.Server
  when PROJAX_MCP_TOKEN is set; /mcp/* gets a StripPrefix and bypasses
  the Supabase-cookie auth middleware (its own Bearer auth applies).
- Off cleanly when the token is unset.

ops:
- ~/.claude/mcp/projax.sh stdio→HTTP bridge (NDJSON in, NDJSON out,
  Bearer header).
- .mcp.json adds an http-transport entry for clients that speak
  HTTP+MCP natively.
- deploy/dokploy.yaml advertises PROJAX_MCP_TOKEN as a secret.
- docs/design.md §7 added: tool list, multi-parent semantics, env
  contract, transport + bridge.
2026-05-15 17:59:03 +02:00

582 lines
16 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)
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
Gitea *GiteaDeps // nil → Gitea integration disabled
MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
}
// 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
},
"join": func(sep string, parts []string) string { return strings.Join(parts, sep) },
"contains": func(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
},
"tagToggleURL": func(active []string, tag string, isActive bool) string {
next := []string{}
if isActive {
for _, t := range active {
if t != tag {
next = append(next, t)
}
}
} else {
next = append(next, active...)
next = append(next, tag)
}
if len(next) == 0 {
return "/"
}
return "/?tag=" + strings.Join(next, ",")
},
}
pages := map[string]*template.Template{}
for _, name := range []string{"tree", "new", "classify", "caldav_admin", "caldav_disabled", "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
}
// detail bundles the shared tasks-section + issues-section partials so
// HTMX swaps and the initial page render hit the same template definitions.
detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/detail.tmpl",
"templates/tasks_section.tmpl",
"templates/issues_section.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse detail: %w", err)
}
pages["detail"] = detailTmpl
// Standalone tasks-section template for HTMX fragment responses.
tasksFragment, err := template.New("tasks_section").Funcs(funcs).ParseFS(templatesFS, "templates/tasks_section.tmpl")
if err != nil {
return nil, fmt.Errorf("parse tasks_section: %w", err)
}
pages["tasks_section"] = tasksFragment
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 /admin/caldav", s.handleCalDAVAdmin)
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
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")
})
if s.MCP != nil {
mux.Handle("/mcp/", http.StripPrefix("/mcp", s.MCP))
}
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
}
tags, err := s.Store.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
activeTags := parseCSV(r.URL.Query().Get("tag"))
roots, orphans, total, orphanN := buildForest(items, activeTags)
s.render(w, "tree", map[string]any{
"Title": "tree",
"Roots": roots,
"Orphans": orphans,
"Total": total,
"OrphanN": orphanN,
"AllTags": tags,
"ActiveTags": activeTags,
})
}
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
}
tasks, err := s.detailTodos(r.Context(), it)
if err != nil {
s.Logger.Warn("detail tasks", "path", it.PrimaryPath(), "err", err)
}
issues, err := s.detailIssues(r.Context(), it)
if err != nil {
s.Logger.Warn("detail issues", "path", it.PrimaryPath(), "err", err)
}
openTotal := 0
for _, ri := range issues {
openTotal += ri.OpenCount
}
s.render(w, "detail", map[string]any{
"Title": it.Title,
"Item": it,
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
"Tasks": tasks,
"CalDAVOn": s.CalDAV != nil,
"Issues": issues,
"IssuesOpenTotal": openTotal,
"GiteaOn": s.Gitea != nil,
})
}
func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/i/")
if base, ok := strings.CutSuffix(path, "/reparent"); ok {
s.handleReparent(w, r, base)
return
}
if base, ok := strings.CutSuffix(path, "/caldav/create"); ok {
s.handleCalDAVCreate(w, r, base)
return
}
for _, action := range []string{"complete", "reopen", "edit", "delete", "todo-create"} {
if base, ok := strings.CutSuffix(path, "/caldav/todo/"+action); ok {
s.handleCalDAVTodoAction(w, r, base, action)
return
}
}
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
parentIDs := r.Form["parent_ids"]
if len(parentIDs) == 0 {
// Legacy single-value field for the classify HTMX action.
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
parentIDs = []string{v}
}
}
parentIDs = dedupeStrings(parentIDs)
in := store.UpdateInput{
Title: strings.TrimSpace(r.FormValue("title")),
Slug: strings.TrimSpace(r.FormValue("slug")),
ParentIDs: parentIDs,
ContentMD: r.FormValue("content_md"),
Status: strings.TrimSpace(r.FormValue("status")),
Pinned: r.FormValue("pinned") == "1",
Archived: r.FormValue("archived") == "1",
Tags: parseCSV(r.FormValue("tags")),
Management: parseCSV(r.FormValue("management")),
}
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.PrimaryPath(), http.StatusSeeOther)
}
// handleReparent replaces parent_ids. /admin/classify uses this to move
// a root mai-managed item under a chosen parent without touching other fields.
// HTMX-friendly: returns a fragment when HX-Request is set.
func (s *Server) handleReparent(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 err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
parentIDs := r.Form["parent_ids"]
if len(parentIDs) == 0 {
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
parentIDs = []string{v}
}
}
parentIDs = dedupeStrings(parentIDs)
if len(parentIDs) == 0 {
http.Error(w, "reparent: parent_ids required", http.StatusBadRequest)
return
}
moved, err := s.Store.Reparent(r.Context(), it.ID, parentIDs)
if err != nil {
s.fail(w, r, err)
return
}
if r.Header.Get("HX-Request") == "true" {
fmt.Fprintf(w, `<tr class="classified"><td colspan="5">Moved to <a href="/i/%s">%s</a></td></tr>`,
template.HTMLEscapeString(moved.PrimaryPath()), template.HTMLEscapeString(moved.PrimaryPath()))
return
}
http.Redirect(w, r, "/i/"+moved.PrimaryPath(), http.StatusSeeOther)
}
// dedupeStrings preserves order, drops empties.
func dedupeStrings(in []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// parseCSV splits a comma/space-delimited chip input into a deduplicated,
// trimmed lowercase string slice. Empty input → []string{} (nil avoided so
// JSON/SQL writes get an explicit empty array).
func parseCSV(raw string) []string {
if strings.TrimSpace(raw) == "" {
return []string{}
}
seen := map[string]struct{}{}
out := []string{}
for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n'
}) {
t := strings.ToLower(strings.TrimSpace(part))
if t == "" {
continue
}
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
return out
}
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
}
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"
}
parentIDs := r.Form["parent_ids"]
if len(parentIDs) == 0 {
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
parentIDs = []string{v}
}
}
parentIDs = dedupeStrings(parentIDs)
in := store.CreateInput{
Kind: []string{kind},
Title: strings.TrimSpace(r.FormValue("title")),
Slug: strings.TrimSpace(r.FormValue("slug")),
ParentIDs: parentIDs,
ContentMD: r.FormValue("content_md"),
Status: strings.TrimSpace(r.FormValue("status")),
Tags: parseCSV(r.FormValue("tags")),
Management: parseCSV(r.FormValue("management")),
}
it, err := s.Store.Create(r.Context(), in)
if err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/i/"+it.PrimaryPath(), 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 {
// Surface every primary path as a candidate parent — multi-parent
// items appear once per parent option using their primary path so the
// UI stays unambiguous.
out = append(out, ParentOption{ID: it.ID, Path: it.PrimaryPath()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out, nil
}
// buildForest groups items_unified rows by parent into a sortable tree. A
// multi-parent item appears under EACH of its parents (duplicated nodes in
// distinct branches). When activeTags is non-empty, a branch is kept only
// when it (or any descendant) matches every active tag. orphans lists
// mai-managed root items so /admin/classify and the tree page can surface
// them.
func buildForest(items []*store.Item, activeTags []string) (roots []*treeNode, orphans []*store.Item, total, orphanN int) {
for _, it := range items {
total++
if len(it.ParentIDs) == 0 && it.HasManagement("mai") {
orphans = append(orphans, it)
orphanN++
}
}
// Build a forest where every parent relationship creates a node — so
// multi-parent items are rendered under each parent. Root items get
// one node total.
nodeFor := func(it *store.Item) *treeNode { return &treeNode{Item: it} }
rootNodes := []*treeNode{}
childByParent := make(map[string][]*treeNode, len(items))
for _, it := range items {
if len(it.ParentIDs) == 0 {
rootNodes = append(rootNodes, nodeFor(it))
continue
}
for _, pid := range it.ParentIDs {
childByParent[pid] = append(childByParent[pid], nodeFor(it))
}
}
var attach func(n *treeNode)
attach = func(n *treeNode) {
n.Children = childByParent[n.Item.ID]
for _, c := range n.Children {
attach(c)
}
}
for _, r := range rootNodes {
attach(r)
}
roots = rootNodes
if len(activeTags) > 0 {
var keep func(n *treeNode) bool
keep = func(n *treeNode) bool {
filtered := n.Children[:0]
for _, c := range n.Children {
if keep(c) {
filtered = append(filtered, c)
}
}
n.Children = filtered
if nodeHasAllTags(n.Item, activeTags) {
return true
}
return len(n.Children) > 0
}
filtered := roots[:0]
for _, n := range roots {
if keep(n) {
filtered = append(filtered, n)
}
}
roots = filtered
}
sort.Slice(roots, func(i, j int) bool { return roots[i].Item.Slug < roots[j].Item.Slug })
sort.Slice(orphans, func(i, j int) bool { return orphans[i].Slug < orphans[j].Slug })
var sortChildren func(n *treeNode)
sortChildren = func(n *treeNode) {
sort.Slice(n.Children, func(i, j int) bool { return n.Children[i].Item.Slug < n.Children[j].Item.Slug })
for _, c := range n.Children {
sortChildren(c)
}
}
for _, r := range roots {
sortChildren(r)
}
return
}
func nodeHasAllTags(it *store.Item, want []string) bool {
for _, t := range want {
if !it.HasTag(t) {
return false
}
}
return true
}
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"
switch name {
case "login":
// Login page is intentionally standalone — no nav chrome.
entry = "login"
case "tasks_section":
// HTMX fragment — no layout chrome.
entry = "tasks-section"
}
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)
})
}