ZDDC/zddc/internal/handler/formhandler.go
ZDDC 03fa366814 feat(server): table/form specs resolve from .zddc.d/ + server-inject the table spec
The supporting config files (table.yaml, form.yaml) can now live in the
admin-gated, hidden `<dir>/.zddc.d/` reserve instead of the directory root —
the `.zddc`-declares / `.zddc.d/`-carries split. Backward-compatible: the
legacy root location still resolves (preferred order: .zddc.d/ → root →
embedded default).

Because `.zddc.d/` is non-fetchable over HTTP for non-admins, the spec is
resolved server-side and INJECTED:
- handler: LoadViewSpec(dir, name) resolves .zddc.d/ → root → embedded
  (classifyDefaultSpec is now location-agnostic — strips a `.zddc.d` segment).
- ServeTable injects the parsed table spec + row schema into the existing
  #table-context as {spec, rowSchema}; RecognizeTableRequest also recognizes a
  spec under .zddc.d/.
- formhandler loadFormSpec + specEligible prefer .zddc.d/form.yaml (forms
  already inject #form-context, so server-only).
- client (tables/js/context.js): walkServer uses the injected spec/rowSchema
  when present (server mode) and still walks the directory for ROW files; FS-
  Access mode reads .zddc.d/<name> (then legacy root) via readYamlFirst. load()
  passes the injected context through. Regenerated the embedded tables.html.

go build/vet/test ./... green; all 40 tables Playwright specs pass; the
ServeTable test now asserts the injected spec.

Remaining (next): file→form URL shape, retiring the recognizers in favour of
ServeView/views:, defaults.zddc.yaml views declaration, writers→.zddc.d/, and
the migration script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:20:55 -05:00

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.
// "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))
}