757 lines
26 KiB
Go
757 lines
26 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 (
|
|
"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 <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",
|
|
// 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 <name>.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: /<project>/ssr/form.html maps to the special create
|
|
// path that materializes a new party folder (mkdir archive/<name>/)
|
|
// AND writes archive/<name>/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 <project>/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: /<project>/(mdl|rsk)/form.html
|
|
// reads a `party` field from the body and routes the new row to
|
|
// <project>/archive/<party>/<slot>/. Recognized before the generic
|
|
// /<dir>/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 {
|
|
dir, base := filepath.Split(specAbs)
|
|
if fileExists(filepath.Join(filepath.Clean(dir), ".zddc.d", base)) {
|
|
return true
|
|
}
|
|
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:
|
|
// /<dir>/form.html — empty form / create
|
|
// /<dir>/<id>.yaml.html — re-edit / update one row
|
|
// Spec is always <dir>/form.yaml relative to the URL.
|
|
if strings.HasSuffix(underlying, "/form") || underlying == "/form" {
|
|
// /<dir>/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") {
|
|
// /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the
|
|
// SAME directory as the row file (<dir>/form.yaml). Register rows
|
|
// are real files now (ssr/<party>.yaml, mdl|rsk/<party>/<file>.yaml),
|
|
// so the in-dir rule resolves them directly — the spec falls back
|
|
// to the embedded default via IsDefaultSpecAbs when no on-disk
|
|
// form.yaml exists.
|
|
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 <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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
var target, fname string
|
|
|
|
// Record path: when a records: rule with a filename_format applies
|
|
// in this directory, route the create through the same compose +
|
|
// folder_fields + audit + history machinery as a PUT or the project
|
|
// rollup — NOT the generic date+email submission write. This keeps
|
|
// in-dir "+ Add row" on a per-party mdl/rsk table consistent with
|
|
// every other record-write entry point (no un-stamped, un-composed
|
|
// rows leaking in through this door).
|
|
rowChain, perr := zddc.EffectivePolicy(cfg.Root, submissionsDir)
|
|
if perr != nil {
|
|
http.Error(w, "cascade resolve: "+perr.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, rule, hasRule := rowChain.EffectiveRecordRule("placeholder.yaml"); hasRule && rule.FilenameFormat != "" {
|
|
dataMap, ok := data.(map[string]interface{})
|
|
if !ok {
|
|
http.Error(w, "request body must be a YAML/JSON object", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var composeErr *jsonschema.Error
|
|
fname, composeErr, err = recordCreatePrep(cfg, submissionsDir, rule, dataMap)
|
|
if err != nil {
|
|
http.Error(w, "record prep: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if composeErr != nil {
|
|
writeValidationErrors(w, []jsonschema.Error{*composeErr})
|
|
return
|
|
}
|
|
target = filepath.Join(submissionsDir, fname)
|
|
if _, statErr := os.Stat(target); statErr == nil {
|
|
http.Error(w, "Conflict — a row with this composed tracking number already exists", http.StatusConflict)
|
|
return
|
|
}
|
|
yamlBytes, mErr := yaml.Marshal(dataMap)
|
|
if mErr != nil {
|
|
http.Error(w, "marshal yaml: "+mErr.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, verrs, herr := WriteWithHistory(cfg, target, req.SubmitURL, yamlBytes, email); herr != nil {
|
|
http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError)
|
|
return
|
|
} else if len(verrs) > 0 {
|
|
writeValidationErrors(w, verrs)
|
|
return
|
|
}
|
|
} else {
|
|
// Generic submission: server-dated, email-tagged filename + a
|
|
// plain write (no audit stamping — these aren't records).
|
|
dateStr := time.Now().UTC().Format("2006-01-02")
|
|
emailSan := sanitizeEmail(email)
|
|
base := dateStr + "-" + emailSan
|
|
var ok bool
|
|
target, fname, ok = pickAvailableFilename(submissionsDir, base, ".yaml")
|
|
if !ok {
|
|
http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict)
|
|
return
|
|
}
|
|
yamlBytes, mErr := yaml.Marshal(data)
|
|
if mErr != nil {
|
|
http.Error(w, "marshal yaml: "+mErr.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if wErr := zddc.WriteAtomic(target, yamlBytes); wErr != nil {
|
|
http.Error(w, "write: "+wErr.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 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
|
|
}
|
|
// Route through WriteWithHistory: for record paths (matching a
|
|
// records: rule) this stamps audit fields, captures prior bytes into
|
|
// .history/, re-derives folder_fields, and enforces that the body
|
|
// still composes to the existing filename — a tracking-number
|
|
// component can't be edited in place (that's a delete + create). For
|
|
// non-record submissions WriteWithHistory falls through to a plain
|
|
// atomic write of the body.
|
|
_, verrs, herr := WriteWithHistory(cfg, req.DataPath, req.SubmitURL, yamlBytes, email)
|
|
if herr != nil {
|
|
http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if len(verrs) > 0 {
|
|
writeValidationErrors(w, verrs)
|
|
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) {
|
|
// Prefer the supporting-files reserve: a spec at <dir>/.zddc.d/form.yaml
|
|
// takes precedence over the legacy <dir>/form.yaml. `path` is the legacy
|
|
// <dir>/form.yaml location the callers build.
|
|
dir, base := filepath.Split(path)
|
|
data, err := os.ReadFile(filepath.Join(filepath.Clean(dir), ".zddc.d", base))
|
|
if err != nil {
|
|
data, err = os.ReadFile(path)
|
|
}
|
|
if err != nil {
|
|
// Default-spec virtual fallback: when no operator file exists in
|
|
// either location, 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).
|
|
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.
|
|
// "sam@proton.me" → "sam-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))
|
|
}
|