ZDDC/zddc/internal/handler/tablehandler.go
ZDDC 821ed3ee19 feat(handler): mdl/ → table-app default with embedded fallback spec
Three pieces wire the per-party Master Deliverables List as the default
view at archive/<party>/mdl/:

1. **Dispatcher redirect.** GET (and HEAD) on
   <project>/archive/<party>/mdl/ (case-fold on archive and mdl) now
   302 → <project>/archive/<party>/mdl.table.html. Non-archive paths
   and deeper mdl/ paths fall through unchanged.

2. **Default-spec fallback in RecognizeTableRequest.** When a request
   matches archive/<party>/mdl.table.html and no operator-supplied
   tables: { mdl: ... } declaration covers it, the handler returns a
   recognised request anyway. Operator declarations still win — and a
   typo'd declaration pointing at a missing file yields 404 (not a
   silent fallback).

3. **Static-file fallback for the spec yaml.** GET archive/<party>/
   mdl.table.yaml and archive/<party>/mdl.form.yaml return embedded
   default bytes (default-mdl.{table,form}.yaml in the handler package)
   when no operator file exists at that path. Operator files always
   win because the dispatcher's os.Stat finds them before reaching the
   IsDefaultMdlSpec branch.

The defaults use ZDDC vocabulary: tracking, title, discipline, type,
plannedRevision, plannedDate, status (DFT/IFR/IFA/IFC/AFC/AB), owner,
notes. Operators override per-party by writing
archive/<party>/{mdl.table.yaml,mdl.form.yaml} and a tables: { mdl: ... }
entry in the party's .zddc.

Tests:
- 4 dispatcher redirect cases (success, case-fold mdl, case-fold archive,
  deeper-path skip, non-archive skip)
- 6 tablehandler cases (default fires at archive/<party>/, operator
  override wins, scope check, embedded yaml served, operator yaml wins,
  scope check on yaml fallback)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:26:53 -05:00

235 lines
8.3 KiB
Go

// Package handler — tablehandler.go: directory-of-YAML table view.
//
// URL convention:
//
// GET /<dir>/<name>.table.html → tables.html, only when <dir>/.zddc
// declares tables: { <name>: <spec-path> }
// AND the spec file exists.
//
// Discovery is .zddc-declarative (no auto-mount on file presence). The
// handler's only jobs are:
//
// 1. Recognize the URL — does <dir>/.zddc declare a table named <name>,
// and does the referenced *.table.yaml spec actually exist? If not,
// fall through to the static-file pipeline.
// 2. Gate on the cascading ACL at the request directory (read action).
// 3. Serve the static tables.html bytes.
//
// All rendering is client-side. The page (built from tables/) detects
// HTTP vs file:// mode, then walks the directory via shared/zddc-source.js
// (HttpDirectoryHandle in HTTP mode, real FileSystemDirectoryHandle in
// file mode), reads .zddc, parses the spec, and renders rows in the
// browser. The server does not pre-parse the spec, list rows, or compute
// per-row "editable" — the client does, and ACL is enforced naturally:
// HTTP-mode reads/writes go through the cascade-aware file API, and
// local-mode reads/writes are bounded by whatever the OS gave the
// FS-Access handle.
package handler
import (
_ "embed"
"log/slog"
"net/http"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
//go:embed tables.html
var embeddedTablesHTML []byte
//go:embed default-mdl.table.yaml
var embeddedDefaultMdlTable []byte
//go:embed default-mdl.form.yaml
var embeddedDefaultMdlForm []byte
// DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes.
// Used by the static-file handler to serve the default spec at
// archive/<party>/mdl.table.yaml when no operator file exists on disk.
func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable }
// DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes.
func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm }
// IsDefaultMdlSpec reports whether (urlPath, dirAbs) describes a request
// for the default mdl.table.yaml or mdl.form.yaml under archive/<party>/
// where no operator file exists. Caller is the static-file handler.
//
// Returns the embedded bytes + true when the fallback should fire.
// Returns nil + false when an operator-supplied file exists or the path
// is not eligible for the fallback.
func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) {
base := strings.ToLower(filepath.Base(urlPath))
var bytes []byte
switch base {
case "mdl.table.yaml":
bytes = embeddedDefaultMdlTable
case "mdl.form.yaml":
bytes = embeddedDefaultMdlForm
default:
return nil, false
}
if !isAtArchivePartyLevel(fsRoot, urlPath) {
return nil, false
}
// Operator file wins if it exists on disk.
rel := strings.TrimPrefix(filepath.ToSlash(urlPath), "/")
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot {
return nil, false
}
if fileExists(abs) {
return nil, false
}
return bytes, true
}
// isAtArchivePartyLevel reports whether urlPath refers to a file
// directly under <project>/archive/<party>/ (depth-3 directory). The
// canonical-folder names are case-folded.
func isAtArchivePartyLevel(fsRoot, urlPath string) bool {
rel := strings.Trim(filepath.ToSlash(urlPath), "/")
parts := strings.Split(rel, "/")
// <project>/archive/<party>/<file> = 4 segments
if len(parts) != 4 {
return false
}
return strings.EqualFold(parts[1], "archive")
}
// TableRequest describes a recognized table-system request.
type TableRequest struct {
// Name is the table's URL stem (the key declared in .zddc tables).
Name string
// SpecPath is the absolute filesystem path to the *.table.yaml.
// Validated to exist at recognition time.
SpecPath string
// Dir is the absolute path to the request directory (where the
// .zddc declared the table).
Dir string
}
// RecognizeTableRequest classifies r as a table-system request, or
// returns nil if it falls through to other handlers. Discovery is
// strictly .zddc-declarative — a *.table.html URL with no matching
// `tables:` entry in <dir>/.zddc returns nil so it falls through to
// the static-file path (404 unless an operator dropped a real file).
//
// Methods other than GET return nil — the table is read-only at the
// URL level. Writes go through the file API directly.
func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
if method != http.MethodGet {
return nil
}
if !strings.HasSuffix(urlPath, ".table.html") {
return nil
}
// Split <dir>/<name>.table.html into dir + name.
stem := strings.TrimSuffix(urlPath, ".table.html")
if stem == "" || stem == "/" {
return nil
}
dirRel := filepath.Dir(filepath.FromSlash(strings.TrimPrefix(stem, "/")))
name := filepath.Base(filepath.FromSlash(strings.TrimPrefix(stem, "/")))
if name == "" || name == "." || name == "/" {
return nil
}
dirAbs := filepath.Join(fsRoot, dirRel)
if !strings.HasPrefix(dirAbs, fsRoot+string(filepath.Separator)) && dirAbs != fsRoot {
return nil
}
zddcPath := filepath.Join(dirAbs, ".zddc")
zf, err := zddc.ParseFile(zddcPath)
if err != nil && !isNotExistError(err) {
// Malformed .zddc — log and pass through; static handler will 500
// if it cares. Recognition just says "not a declared table here."
slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err)
return nil
}
if specRel, ok := zf.Tables[name]; ok {
// Operator explicitly declared this table — honour it strictly.
// If the declared spec file is missing, return nil so the URL
// 404s rather than silently falling back to the default. This
// keeps a typo in the operator's .zddc visible.
specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel))
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
return nil
}
if !fileExists(specAbs) {
return nil
}
return &TableRequest{
Name: name,
SpecPath: specAbs,
Dir: dirAbs,
}
}
// No operator declaration — apply the default MDL spec fallback at
// archive/<party>/. The spec bytes are served by IsDefaultMdlSpec via
// the static-file dispatcher.
if strings.EqualFold(name, "mdl") && isArchivePartyDir(fsRoot, dirAbs) {
return &TableRequest{
Name: "mdl",
SpecPath: filepath.Join(dirAbs, "mdl.table.yaml"), // virtual; static handler may serve embedded bytes
Dir: dirAbs,
}
}
return nil
}
// isNotExistError reports whether err indicates a missing file. Local
// helper to avoid pulling errors.Is into the handler package.
func isNotExistError(err error) bool {
return err != nil && strings.Contains(err.Error(), "no such file or directory")
}
// isArchivePartyDir reports whether dirAbs is a <project>/archive/<party>/
// directory under fsRoot, with archive case-folded.
func isArchivePartyDir(fsRoot, dirAbs string) bool {
rel, err := filepath.Rel(fsRoot, dirAbs)
if err != nil {
return false
}
rel = filepath.ToSlash(rel)
if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." {
return false
}
parts := strings.Split(rel, "/")
if len(parts) != 3 {
return false
}
return strings.EqualFold(parts[1], "archive")
}
// ServeTable serves the static tables.html bytes for a recognized
// request. ACL gate is the read action at the request directory; on
// allow, the embedded HTML is written verbatim. The client takes over
// from there — see tables/js/main.js.
func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
decider := DeciderFromContext(r)
chain, err := zddc.EffectivePolicy(cfg.Root, req.Dir)
if err != nil {
slog.Warn("table: policy error", "path", req.Dir, "err", err)
}
if allowed, _ := policy.AllowActionFromChain(r.Context(), decider, chain, email, r.URL.Path, policy.ActionRead); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if len(embeddedTablesHTML) == 0 {
http.Error(w, "table renderer not built into this binary", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(embeddedTablesHTML)
}