// 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 //.form.html → render empty form // POST //.form.html → create new submission → 201 + Location // GET ///.yaml.html → render form pre-filled from .yaml // POST ///.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 /safety.form.yaml; // submissions go to /safety/-.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 ( "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/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "gopkg.in/yaml.v3" ) // Form-mode rendering shares the unified `tables.html` bundle: one HTML // hosts both apps (tablesApp, formApp) so the table view's "+ Add row" // link, the empty-form / create URL, and the row-edit URL all return // the same bytes. A small mode dispatcher in the bundle picks which // app paints based on the URL pattern. This eliminates a second // embedded HTML and lets future editable-cell mode reuse the form // validator + write path without IPC across two SPAs. // // formRenderHTML is the source of bytes for serveFormRender; it's the // same package var as embeddedTablesHTML (declared in tablehandler.go). func formRenderHTML() []byte { return embeddedTablesHTML } // FormSpec is the YAML envelope of a .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", // or "create-via-ssr" (the special SSR create flow which materializes // a new party folder + ssr.yaml). Kind string // SpecPath is the absolute filesystem path to the .form.yaml. SpecPath string // DataPath is the absolute filesystem path to the data .yaml; empty for // render-empty / create / create-via-ssr. DataPath string // SubmitURL is the URL the form should POST back to (the server-injected // "submit to my own URL" value). SubmitURL string // Project carries the project name for create-via-ssr / // create-via-rollup requests. Empty for all other kinds. Project string // Slot carries the slot name ("mdl" or "rsk") for create-via-rollup // requests. Empty for all other kinds. Slot 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 } // SSR create: //ssr/form.html maps to the special create // path that materializes a new party folder (mkdir archive//) // AND writes archive//ssr.yaml. Recognized before the generic // form.html branch so it doesn't get misrouted as an in-dir create. if project, ok := zddc.IsSSRCreateURL(urlPath); ok { kind := "render-empty" if method == http.MethodPost { kind = "create-via-ssr" } // SpecPath is the embedded default SSR form schema; the loader // falls back to embedded bytes via IsDefaultSpecAbs. The path // itself is the virtual /ssr/form.yaml location. specAbs := filepath.Join(fsRoot, project, "ssr", "form.yaml") return &FormRequest{ Kind: kind, SpecPath: specAbs, SubmitURL: urlPath, Project: project, } } // Project-rollup MDL / RSK create: //(mdl|rsk)/form.html // reads a `party` field from the body and routes the new row to // /archive///. Recognized before the generic // //form.html branch so a virtual rollup URL doesn't get // misrouted as an in-dir create. if project, slot, ok := zddc.IsRollupCreateURL(urlPath); ok { kind := "render-empty" if method == http.MethodPost { kind = "create-via-rollup" } specAbs := filepath.Join(fsRoot, project, slot, "form.yaml") return &FormRequest{ Kind: kind, SpecPath: specAbs, SubmitURL: urlPath, Project: project, Slot: slot, } } underlying := strings.TrimSuffix(urlPath, ".html") // specEligible accepts a spec path that exists on disk OR matches // any of the default-spec virtual-fallback shapes (per-party // mdl/rsk, per-party SSR schema, project-level virtual specs). specEligible := func(specAbs string) bool { if fileExists(specAbs) { return true } if _, ok := IsDefaultSpecAbs(fsRoot, specAbs); ok { return true } return false } // In-dir convention: spec, form, and rows all live in one // directory. URLs: // //form.html — empty form / create // //.yaml.html — re-edit / update one row // Spec is always /form.yaml relative to the URL. if strings.HasSuffix(underlying, "/form") || underlying == "/form" { // //form.html — empty form / create. dirRel := strings.TrimSuffix(strings.TrimPrefix(underlying, "/"), "/form") dirRel = strings.TrimSuffix(dirRel, "form") // root case "/form" → "" dirRel = strings.Trim(dirRel, "/") if dirRel == "" { // /form.html at root has no rows-dir to bind a spec to. return nil } dirAbs := filepath.Join(fsRoot, filepath.FromSlash(dirRel)) if !strings.HasPrefix(dirAbs, fsRoot+string(filepath.Separator)) && dirAbs != fsRoot { return nil } specAbs := filepath.Join(dirAbs, "form.yaml") if !specEligible(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") { // //.yaml.html — re-edit / update. Spec lives in the // SAME directory as the row file (/form.yaml) UNLESS the // URL maps to one of the project-level virtual views, in which // case the canonical SpecPath / DataPath are inside the per- // party archive folder. ResolveVirtualView handles the rewrite; // SubmitURL stays as the virtual URL so the form POSTs back to // the same endpoint (which re-resolves to the same canonical // paths on the second pass). if vv := zddc.ResolveVirtualView(fsRoot, underlying); vv.Resolved && vv.Kind.IsRowKind() { var specPath string switch vv.Kind { case zddc.VirtualViewSSRRow: specPath = vv.SchemaAbs case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow: specPath = filepath.Join(vv.PartyArchive, vv.Slot, "form.yaml") } if !specEligible(specPath) { return nil } kind := "render-edit" if method == http.MethodPost { kind = "update" } return &FormRequest{ Kind: kind, SpecPath: specPath, DataPath: vv.CanonicalAbs, SubmitURL: urlPath, } } 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 } specPath := filepath.Join(filepath.Dir(dataAbs), "form.yaml") if !specEligible(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) case "create-via-ssr": serveFormCreateSSR(cfg, req, w, r) case "create-via-rollup": serveFormCreateRollup(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) { // 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 allowed, _ := policy.AllowFromChainP(r.Context(), DeciderFromContext(r), chain, PrincipalFromContext(r), r.URL.Path); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return } if len(formRenderHTML()) == 0 { http.Error(w, "form renderer not built into this binary", http.StatusServiceUnavailable) return } spec, err := loadFormSpec(cfg.Root, 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) } // Augment the schema with cascade-resolved field_codes (enum / // pattern / labels) and any records: rule that applies in this // folder (readOnly for locked fields, default for field_defaults). // The augmentation is per-request and never mutates the on-disk // spec — it's purely additive context the form renderer needs. augmentSchemaFromCascade(spec.Schema, chain, gateDir) ctx := formContext{ Title: spec.Title, Schema: spec.Schema, UI: spec.UI, Data: data, SubmitURL: req.SubmitURL, Errors: validationErrs, } html, err := injectFormContext(formRenderHTML(), 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 .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 } // In-dir convention: spec, form, and rows live in one directory. // New submissions land alongside the spec; submissionsDir IS the // directory holding form.yaml. submissionsDir := filepath.Dir(req.SpecPath) // ACL: write-rights at the directory where the row YAML will land. // In the default-MDL fallback case the directory may not exist // yet; cascade up to the closest existing ancestor for the policy // chain. gateDir := submissionsDir if !fileExists(submissionsDir) { gateDir = filepath.Dir(submissionsDir) } chain, err := zddc.EffectivePolicy(cfg.Root, gateDir) if err != nil { slog.Warn("form: policy error", "path", gateDir, "err", err) } if allowed, _ := policy.AllowFromChainP(r.Context(), DeciderFromContext(r), chain, PrincipalFromContext(r), r.URL.Path); !allowed { 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(cfg.Root, 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 /.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 allowed, _ := policy.AllowFromChainP(r.Context(), DeciderFromContext(r), chain, PrincipalFromContext(r), r.URL.Path); !allowed { 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(cfg.Root, 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(fsRoot, path string) (*FormSpec, error) { data, err := os.ReadFile(path) if err != nil { // Default-spec virtual fallback: when no operator file exists at // path, serve the embedded default if path matches one of the // recognized virtual fallback shapes (per-party mdl/rsk, per- // party SSR schema, project-level virtual specs). Mirrors the // static-handler fallback for direct YAML fetches. if os.IsNotExist(err) { if bytes, ok := IsDefaultSpecAbs(fsRoot, path); ok { data = bytes } else { return nil, err } } else { 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 ``, then `-2`, ..., // `-100`, 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 ". 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), "{}`) if !bytesContains(template, needle) { return nil, errors.New("#form-context placeholder not found in template") } replacement := append([]byte(``)...) 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)) }