ZDDC/zddc/internal/handler/formhandler.go
ZDDC a02a26d3c2
All checks were successful
Build + deploy releases / build-and-deploy (push) Successful in 8s
feat: form-data system v0 (sixth tool + zddc-server endpoints)
Schema-driven form renderer plus zddc-server endpoints that turn any
<name>.form.yaml into a working data-collection form at <path>/<name>.form.html.
Submissions land in <path>/<name>/<YYYY-MM-DD>-<email-sanitized>.yaml,
ACL-gated by the existing .zddc cascade. The form posts back to its own URL;
the server strips ".html" and routes by what's underneath, so create and
update use the same client-side code path.

Form spec dialect: JSON Schema 2020-12 + RJSF-style ui:* hints, written in
YAML. Chosen for LLM authorability — it's the canonical structured-output
target for OpenAI/Anthropic, and the ui:* convention is the most-trained UI
hint vocabulary. Supported subset for v0: type (string/number/integer/boolean/
array/object), enum, min/max, minLength/maxLength, required, additionalProperties:
false, properties, items, format (date, email). Round-trip mode is form-as-truth:
submission YAML is regenerated each save, comments are not preserved (the v1
file-as-truth mode for hand-edited files like .zddc itself is deferred).

New components:
  * form/ — sixth single-file HTML tool, vanilla JS renderer (~760 LoC)
  * zddc/internal/jsonschema/ — focused JSON Schema validator covering only
    the v0 keyword subset. Match-implementation-cost-to-surface-used: a full
    library brings 70%+ surface we don't use; revisit when v1 adds $ref +
    oneOf + if/then/else.
  * zddc/internal/handler/formhandler.go — RecognizeFormRequest / ServeForm,
    capability-URL re-edit, atomic submission writes via the new
    zddc.WriteAtomic helper extracted from writer.go.
  * dispatch() in zddc-server/main.go now intercepts *.form.html and
    *.yaml.html before the static-file path; spec existence is the trigger.

Build pipeline: form joins ZDDC_RELEASE_TOOLS in lockstep, gets its own
embedded copy in handler/form.html (separate from the apps cascade —
the form renderer is fixed, not subject to per-folder version overrides).

Tests: 5 new Playwright specs (form-safety) + 14 new Go tests across the
validator and handler. All 172 Playwright tests + 10 Go packages green.
End-to-end manual verification: GET empty → POST 201 + capability URL →
GET re-edit (pre-filled) → POST update → 200, raw YAML browsable, ACL
deny → 403.

Docs: form/ section added to AGENTS.md and ARCHITECTURE.md. AGENTS.md
also documents the implementation-vs-dependency policy. CLAUDE.md repo-shape
list extended.

Deferred (v1+): .zddc editor migration onto this system, file-as-truth
lossless YAML round-trip, ui:show-when conditional visibility, oneOf/anyOf,
apps-cascade preview hook, cascade-fetched form definitions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:12:16 -05:00

578 lines
18 KiB
Go

// Package handler — formhandler.go: the form-data system endpoints.
//
// URL conventions (the form always POSTs to the same URL it was GET'd from;
// the server strips ".html" and routes by what's underneath):
//
// GET /<path>/<name>.form.html → render empty form
// POST /<path>/<name>.form.html → create new submission → 201 + Location
// GET /<path>/<name>/<id>.yaml.html → render form pre-filled from <id>.yaml
// POST /<path>/<name>/<id>.yaml.html → validate + overwrite that submission → 200
//
// Direct GET of the raw .yaml (data) and .form.yaml (spec) continues through
// the existing static-file path; only the .html suffix is hijacked here.
//
// Storage layout: a form named "safety" lives at <dir>/safety.form.yaml;
// submissions go to <dir>/safety/<YYYY-MM-DD>-<email-sanitized>.yaml. The
// submissions folder is created lazily on first POST. ACL via the existing
// .zddc cascade — submit-rights = path-write-rights at the submissions
// directory.
package handler
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"gopkg.in/yaml.v3"
)
//go:embed form.html
var embeddedFormHTML []byte
// FormSpec is the YAML envelope of a <name>.form.yaml file.
//
// v0 fields: Title, Description, Schema, UI. Mode is reserved for the v1
// file-as-truth introduction (default form-as-truth = empty / "form-as-truth").
// Unknown YAML keys are ignored — this struct is the source of truth for the
// supported form-spec vocabulary.
type FormSpec struct {
Title string `yaml:"title"`
Description string `yaml:"description"`
Schema *jsonschema.Schema `yaml:"schema"`
UI map[string]interface{} `yaml:"ui"`
Mode string `yaml:"mode"`
}
// formContext is the JSON object the server injects into the form HTML.
// The renderer (form/js/context.js) reads this from #form-context.
type formContext struct {
Title string `json:"title,omitempty"`
Schema *jsonschema.Schema `json:"schema"`
UI map[string]interface{} `json:"ui,omitempty"`
Data interface{} `json:"data,omitempty"`
SubmitURL string `json:"submitUrl"`
Errors []jsonschema.Error `json:"errors,omitempty"`
}
// FormRequest describes a recognized form-system request.
type FormRequest struct {
// Kind is one of: "render-empty", "create", "render-edit", "update".
Kind string
// SpecPath is the absolute filesystem path to the <name>.form.yaml.
SpecPath string
// DataPath is the absolute filesystem path to the data .yaml; empty for
// render-empty / create.
DataPath string
// SubmitURL is the URL the form should POST back to (the server-injected
// "submit to my own URL" value).
SubmitURL string
}
// RecognizeFormRequest classifies r as a form-system request, or returns nil
// if it falls through to static file serving. Form-spec existence on disk is
// required: a *.form.html URL with no corresponding *.form.yaml is not a
// form request.
//
// Methods other than GET / POST return nil (HEAD / OPTIONS pass through to
// the catch-all so the standard handlers respond).
func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
if method != http.MethodGet && method != http.MethodPost {
return nil
}
if !strings.HasSuffix(urlPath, ".html") {
return nil
}
underlying := strings.TrimSuffix(urlPath, ".html")
// Form-spec URL: <name>.form.html → spec at <name>.form.yaml.
// Data URL: <name>/<id>.yaml.html → underlying ends in .yaml → spec at
// <name>.form.yaml at the parent's parent.
if strings.HasSuffix(underlying, ".form") {
// <name>.form.html — empty form / create.
specRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/"))) + ".yaml"
specAbs := filepath.Join(fsRoot, specRel)
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
return nil
}
if !fileExists(specAbs) {
return nil
}
kind := "render-empty"
if method == http.MethodPost {
kind = "create"
}
return &FormRequest{
Kind: kind,
SpecPath: specAbs,
SubmitURL: urlPath,
}
}
if strings.HasSuffix(underlying, ".yaml") {
// <name>/<id>.yaml.html — re-edit / update.
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
dataAbs := filepath.Join(fsRoot, dataRel)
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
return nil
}
// Spec lives at the parent's parent: <dir>/<name>/<id>.yaml →
// <dir>/<name>.form.yaml.
parentDir := filepath.Dir(dataAbs)
formName := filepath.Base(parentDir)
grandparent := filepath.Dir(parentDir)
specPath := filepath.Join(grandparent, formName+".form.yaml")
if !fileExists(specPath) {
return nil
}
kind := "render-edit"
if method == http.MethodPost {
kind = "update"
}
return &FormRequest{
Kind: kind,
SpecPath: specPath,
DataPath: dataAbs,
SubmitURL: urlPath,
}
}
return nil
}
// ServeForm dispatches a recognized form request to render or write logic.
// The catch-all dispatch in zddc-server/main.go calls this whenever
// RecognizeFormRequest returns non-nil.
func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
switch req.Kind {
case "render-empty":
serveFormRender(cfg, req, w, r, nil)
case "render-edit":
serveFormRender(cfg, req, w, r, nil)
case "create":
serveFormCreate(cfg, req, w, r)
case "update":
serveFormUpdate(cfg, req, w, r)
default:
http.Error(w, "unknown form request kind", http.StatusInternalServerError)
}
}
// serveFormRender handles GET requests for both empty and pre-filled forms.
// validationErrs is non-nil only when re-rendering after a POST→422 (not used
// in v0 — POST returns JSON 422 and the client patches errors into the live
// form via JS).
func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request, validationErrs []jsonschema.Error) {
email := EmailFromContext(r)
// ACL: read-rights at the directory holding the spec (and, for edits, at
// the directory holding the data file). Cascade chain is the same for
// every entity in the same directory — a single check covers both.
gateDir := filepath.Dir(req.SpecPath)
if req.DataPath != "" {
gateDir = filepath.Dir(req.DataPath)
}
chain, err := zddc.EffectivePolicy(cfg.Root, gateDir)
if err != nil {
slog.Warn("form: policy error", "path", gateDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if len(embeddedFormHTML) == 0 {
http.Error(w, "form renderer not built into this binary", http.StatusServiceUnavailable)
return
}
spec, err := loadFormSpec(req.SpecPath)
if err != nil {
slog.Warn("form: spec parse error", "path", req.SpecPath, "err", err)
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
return
}
var data interface{}
if req.DataPath != "" {
if !fileExists(req.DataPath) {
http.NotFound(w, r)
return
}
raw, err := os.ReadFile(req.DataPath)
if err != nil {
http.Error(w, "read submission: "+err.Error(), http.StatusInternalServerError)
return
}
if err := yaml.Unmarshal(raw, &data); err != nil {
http.Error(w, "parse submission: "+err.Error(), http.StatusInternalServerError)
return
}
data = normalizeYAMLForJSON(data)
}
ctx := formContext{
Title: spec.Title,
Schema: spec.Schema,
UI: spec.UI,
Data: data,
SubmitURL: req.SubmitURL,
Errors: validationErrs,
}
html, err := injectFormContext(embeddedFormHTML, ctx)
if err != nil {
http.Error(w, "render: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(html)
}
// serveFormCreate handles POST to <name>.form.html — creates a new submission.
func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
formName := strings.TrimSuffix(filepath.Base(req.SpecPath), ".form.yaml")
specDir := filepath.Dir(req.SpecPath)
submissionsDir := filepath.Join(specDir, formName)
// ACL: write-rights at submissions dir. The dir may not exist yet; the
// cascade chain falls back to the parent.
gateDir := submissionsDir
if !fileExists(submissionsDir) {
gateDir = specDir
}
chain, err := zddc.EffectivePolicy(cfg.Root, gateDir)
if err != nil {
slog.Warn("form: policy error", "path", gateDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
data, err := decodeRequestData(r)
if err != nil {
http.Error(w, "request body: "+err.Error(), http.StatusBadRequest)
return
}
spec, err := loadFormSpec(req.SpecPath)
if err != nil {
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
return
}
if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 {
writeValidationErrors(w, errs)
return
}
if err := os.MkdirAll(submissionsDir, 0o755); err != nil {
http.Error(w, "ensure submissions dir: "+err.Error(), http.StatusInternalServerError)
return
}
dateStr := time.Now().UTC().Format("2006-01-02")
emailSan := sanitizeEmail(email)
base := dateStr + "-" + emailSan
target, fname, ok := pickAvailableFilename(submissionsDir, base, ".yaml")
if !ok {
http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict)
return
}
yamlBytes, err := yaml.Marshal(data)
if err != nil {
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
return
}
if err := zddc.WriteAtomic(target, yamlBytes); err != nil {
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
return
}
// Capability URL: the path to the new submission file. The renderer
// appends ".html" to navigate back to the form-rendered view of the just-
// saved data.
relPath, err := filepath.Rel(cfg.Root, target)
if err != nil {
slog.Warn("form: rel path error", "root", cfg.Root, "target", target, "err", err)
http.Error(w, "post-write: "+err.Error(), http.StatusInternalServerError)
return
}
capURL := "/" + filepath.ToSlash(relPath)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", capURL)
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{
"location": capURL,
"filename": fname,
})
}
// serveFormUpdate handles POST to <name>/<id>.yaml.html — overwrites an
// existing submission after re-validating against the form spec.
func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
if !fileExists(req.DataPath) {
http.NotFound(w, r)
return
}
chain, err := zddc.EffectivePolicy(cfg.Root, filepath.Dir(req.DataPath))
if err != nil {
slog.Warn("form: policy error", "path", req.DataPath, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
data, err := decodeRequestData(r)
if err != nil {
http.Error(w, "request body: "+err.Error(), http.StatusBadRequest)
return
}
spec, err := loadFormSpec(req.SpecPath)
if err != nil {
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
return
}
if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 {
writeValidationErrors(w, errs)
return
}
yamlBytes, err := yaml.Marshal(data)
if err != nil {
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
return
}
if err := zddc.WriteAtomic(req.DataPath, yamlBytes); err != nil {
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}
// --- Helpers -----------------------------------------------------------------
func loadFormSpec(path string) (*FormSpec, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var spec FormSpec
if err := yaml.Unmarshal(data, &spec); err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
if spec.Schema == nil {
return nil, errors.New("form spec has no schema")
}
return &spec, nil
}
// decodeRequestData reads the request body as JSON (preferred) or YAML,
// returning the decoded value as the same `any` shape jsonschema.Validate
// expects. Body size is capped at 1 MiB.
func decodeRequestData(r *http.Request) (interface{}, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
return nil, err
}
if len(body) == 0 {
return nil, errors.New("empty body")
}
ct := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
if ct == "application/yaml" || ct == "application/x-yaml" || ct == "text/yaml" {
var v interface{}
if err := yaml.Unmarshal(body, &v); err != nil {
return nil, err
}
return normalizeYAMLForJSON(v), nil
}
var v interface{}
dec := json.NewDecoder(strings.NewReader(string(body)))
dec.UseNumber()
if err := dec.Decode(&v); err != nil {
return nil, err
}
return normalizeJSONNumbers(v), nil
}
// normalizeYAMLForJSON converts yaml.v3's `map[interface{}]interface{}` (which
// it produces for mappings under a generic `interface{}` target) into
// `map[string]interface{}` so the rest of the pipeline can assume JSON shape.
// Also recurses through slices.
func normalizeYAMLForJSON(v interface{}) interface{} {
switch x := v.(type) {
case map[interface{}]interface{}:
out := make(map[string]interface{}, len(x))
for k, val := range x {
out[fmt.Sprintf("%v", k)] = normalizeYAMLForJSON(val)
}
return out
case map[string]interface{}:
out := make(map[string]interface{}, len(x))
for k, val := range x {
out[k] = normalizeYAMLForJSON(val)
}
return out
case []interface{}:
out := make([]interface{}, len(x))
for i, item := range x {
out[i] = normalizeYAMLForJSON(item)
}
return out
}
return v
}
// normalizeJSONNumbers converts json.Number values into int64 (when integral)
// or float64. Without this, the validator would have to know about
// json.Number, which would couple the focused jsonschema package to the
// json decoder we happen to use here.
func normalizeJSONNumbers(v interface{}) interface{} {
switch x := v.(type) {
case json.Number:
if i, err := x.Int64(); err == nil {
return i
}
if f, err := x.Float64(); err == nil {
return f
}
return x.String()
case map[string]interface{}:
for k, val := range x {
x[k] = normalizeJSONNumbers(val)
}
return x
case []interface{}:
for i, item := range x {
x[i] = normalizeJSONNumbers(item)
}
return x
}
return v
}
func writeValidationErrors(w http.ResponseWriter, errs []jsonschema.Error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"errors": errs,
})
}
// sanitizeEmail produces a safe filename component from an email address.
// "casey@proton.me" → "casey-at-proton-me". Conservative: also flattens any
// path-meaningful characters so a malicious email can't escape its directory,
// then collapses runs of '-' and trims leading/trailing '-' so the resulting
// filename is well-formed.
func sanitizeEmail(s string) string {
s = strings.ReplaceAll(s, "@", "-at-")
s = strings.ReplaceAll(s, ".", "-")
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '-', r == '_':
b.WriteRune(r)
}
}
out := b.String()
for strings.Contains(out, "--") {
out = strings.ReplaceAll(out, "--", "-")
}
out = strings.Trim(out, "-")
if out == "" {
out = "anonymous"
}
return out
}
// pickAvailableFilename tries `<base><ext>`, then `<base>-2<ext>`, ...,
// `<base>-100<ext>`, returning the first that does not yet exist on disk.
func pickAvailableFilename(dir, base, ext string) (path, name string, ok bool) {
name = base + ext
path = filepath.Join(dir, name)
if !fileExists(path) {
return path, name, true
}
for i := 2; i < 100; i++ {
name = base + "-" + strconv.Itoa(i) + ext
path = filepath.Join(dir, name)
if !fileExists(path) {
return path, name, true
}
}
return "", "", false
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// injectFormContext rewrites the embedded form HTML's #form-context placeholder
// with a serialized form context. Defends against script-tag breakouts in the
// JSON values by escaping any "</" sequences as "<\\/" — the JS engine treats
// a backslash-escaped slash identically inside a string literal, so behavior
// is preserved while the HTML parser cannot mistake content for a closing
// </script>.
func injectFormContext(template []byte, ctx formContext) ([]byte, error) {
js, err := json.Marshal(ctx)
if err != nil {
return nil, err
}
js = []byte(strings.ReplaceAll(string(js), "</", "<\\/"))
needle := []byte(`<script id="form-context" type="application/json">{}</script>`)
if !bytesContains(template, needle) {
return nil, errors.New("#form-context placeholder not found in template")
}
replacement := append([]byte(`<script id="form-context" type="application/json">`), js...)
replacement = append(replacement, []byte(`</script>`)...)
out := bytesReplace(template, needle, replacement)
return out, nil
}
// Tiny bytes helpers — scoped here so we don't pull in "bytes" for two calls.
func bytesContains(haystack, needle []byte) bool {
return strings.Contains(string(haystack), string(needle))
}
func bytesReplace(haystack, needle, replacement []byte) []byte {
return []byte(strings.Replace(string(haystack), string(needle), string(replacement), 1))
}