ZDDC/zddc/internal/handler/formhandler.go
ZDDC e6d9966593 refactor(tables): in-dir convention + unified table+form HTML bundle
Two intertwined refactors that share too many files to split cleanly.
Both are described separately below.

PART 1 — in-dir convention for table+form spec files

Old layout had the spec at the parent and rows in a child:

    archive/<party>/
      mdl.table.yaml         spec
      mdl.form.yaml          row-edit form
      mdl/                   rows-dir
        row-001.yaml ...

URLs were /<dir>/mdl.table.html and /<dir>/mdl.form.html. Copying
mdl/ elsewhere lost the spec and form because they lived next door.

New layout collapses everything into the rows-dir:

    archive/<party>/mdl/      self-contained
      table.yaml              spec
      form.yaml               row-edit form
      row-001.yaml ...        rows

URLs become /<dir>/mdl/table.html and /<dir>/mdl/form.html. The
"copying-the-folder-takes-everything" property the user asked for
falls out by construction; the row-edit URL /<dir>/<id>.yaml.html
keeps the same shape (spec is now in the same dir, not the
grandparent).

Server changes:

- internal/handler/tablehandler.go RecognizeTableRequest fires on
  /<dir>/table.html when <dir>/table.yaml exists. The .zddc.tables
  alias map is gone — pure presence-based discovery now matches
  the form system's existing convention. Default-MDL fallback at
  archive/<party>/mdl/ stays for the virgin-archive case (the
  rows-dir need not exist on disk; the URL renders fully virtually).

- internal/handler/formhandler.go RecognizeFormRequest fires on
  /<dir>/form.html and /<dir>/<id>.yaml.html with spec at
  <dir>/form.yaml. specEligible accepts on-disk files OR the
  default-MDL virtual path so an empty mdl/ dir still surfaces the
  add-row form.

- internal/handler/tablehandler.go IsDefaultMdlSpec moves to
  serving archive/<party>/mdl/{table,form}.yaml (5 segments after
  ZDDC_ROOT). New isAtArchivePartyMdlLevel predicate; new
  isAtArchivePartyMdlDir for directory-based recognition. New
  IsDefaultMdlSpecAbs accessor for callers that hold an abs path
  rather than a URL (formhandler).

- internal/handler/formhandler.go loadFormSpec(fsRoot, path) falls
  back to embedded default-MDL bytes when os.ReadFile returns
  NotExist AND the path matches the archive-party-mdl shape. Three
  call sites updated to pass cfg.Root.

- internal/handler/formhandler.go serveFormCreate writes
  submissions to filepath.Dir(req.SpecPath) — the spec, the form,
  and rows all live in one directory. The submissionsDir creation
  is idempotent (MkdirAll); cascade falls back one level for ACL
  evaluation when the dir hasn't been materialized yet.

- internal/handler/tablehandler.go tableRowsRedirect now points at
  /<dir>/table.html (was /<dir>.table.html) when the directory
  request maps to a recognized table.

- cmd/zddc-server/main.go dispatch synth flips from
  urlPath + ".table.html" to urlPath + "/table.html" for the
  no-trailing-slash → tables-app routing.

- internal/apps/availability.go DefaultAppAt comment clarified
  that the dir at archive/<party>/mdl/ IS the table (not a child).

Client changes:

- tables/js/context.js walkServer fetches <currentdir>/table.yaml
  directly — no .zddc walk for table declarations. Rows are every
  *.yaml in current dir EXCLUDING table.yaml and form.yaml. The
  .zddc fetch-for-aliases is gated on file:// (online mode 404s
  on .zddc reads via the dispatcher's reserve guard, so skipping
  the request avoids browser console noise).

- tables/js/main.js add-row button links to relative form.html
  (same dir).

- tables/js/render.js + filters.js: every column's autofilter is
  uniformly a text-contains input, even enum columns — keeps the
  filter row visually consistent and doesn't constrain users to
  the enum vocabulary.

PART 2 — unified table+form HTML bundle

The form-render and table-render code paths share field schemas,
the cell editor for excel-mode IS a form widget, and the form
system's POST-back / validation already exists. Combining the two
HTMLs eliminates duplicating jsyaml/jsonschema/theme/source-
detection/.zddc-parsing across two single-file tools.

- tables/template.html grows two top-level mode containers:
  #table-mode (toolbar + sortable table) and #form-mode (form +
  submit button). Both hidden at parse time; the dispatcher
  unhides one. The shared #form-context placeholder was added
  here so the server's existing injectFormContext target
  resolves.

- tables/js/mode.js (new) sets window.zddcMode synchronously
  based on URL pattern: /form.html or /<id>.yaml.html → form,
  /table.html → table, else inline-context fallback for
  file:// (whichever context blob is non-empty wins). Unhides
  the matching container at DOMContentLoaded.

- tables/js/main.js init() and form/js/main.js boot() each guard
  early when mode isn't theirs. Both apps live on different
  globals (window.tablesApp vs window.formApp) so module
  registration doesn't collide.

- form/js/main.js title write falls back from #form-title to
  #table-title (the unified bundle's shared header element)
  when the dedicated id isn't present.

- tables/build.sh concatenates form modules (widgets, render,
  object, array, errors, post, serialize, util) and form CSS.
  No new external deps. Bundle grows from ~95KB to ~120KB.

- internal/handler/formhandler.go drops the //go:embed form.html
  directive; serveFormRender now writes embeddedTablesHTML via
  a small formRenderHTML() accessor (var declared in
  tablehandler.go, same package). The embedded form.html file
  is removed.

- build script: cp form/dist/form.html → internal/handler/form.html
  step is gone (file no longer exists in the source tree). cp
  tables/dist/tables.html → internal/handler/tables.html now
  runs unconditionally rather than only on beta/stable cuts —
  the renderer is a fixed binary component and dev iteration
  needs the embedded copy refreshed every build. Channel-cascaded
  apps (internal/apps/embedded/) stay channel-gated as before.

- form/dist/form.html still builds for standalone offline-only
  use (downloadable from /releases/), but no longer goes into
  the binary.

Tests:

- internal/handler/tablehandler_test.go and formhandler_test.go
  rewritten for the in-dir layout. New test
  TestRecognizeFormRequest_DefaultMdlAtArchiveParty covers
  empty-form, create POST, re-edit row, and the negative cases
  (Working/, non-mdl name) where the fallback must NOT fire.

- internal/handler/directory_test.go updated for the new
  /<dir>/table.html redirect target.

- cmd/zddc-server/main_test.go TestDispatchSlashRouting Location
  expectation updated.

- tests/form-safety.spec.js loads tables/dist/tables.html
  (named form.html in the temp dir to trigger form-mode in the
  dispatcher) so it tests the same bytes the server returns.
  Title-element selector switches to #table-title.

- tests/tables.spec.js updates the status-filter test for the
  uniform text-input filter.

Docs:

- AGENTS.md form-data system rewrites the URL conventions and
  storage layout for in-dir; gains a Tables system section
  parallel to forms describing the self-contained-directory
  property; subfolder rules ("one table per folder by
  construction; subfolders allowed and silently ignored as rows
  — legitimate uses: nested sub-tables, per-row attachments,
  drafts, future history sidecars") so we don't re-derive this.

Not included (deferred):

- ACL gating on cell-level writes — not relevant until Phase 3.
- Editable cells UI — separate commit (Phase 1).

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

622 lines
20 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".
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")
// specEligible accepts a spec path that exists on disk OR matches
// the default-MDL virtual-fallback shape at archive/<party>/mdl/.
// Without this, the default-MDL row form would 404 on a fresh
// archive even though the table view renders.
specEligible := func(specAbs string) bool {
if fileExists(specAbs) {
return true
}
if _, ok := IsDefaultMdlSpecAbs(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).
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)
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 allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, 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)
}
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.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, 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 <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.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, 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-MDL virtual fallback: when the operator hasn't placed
// an mdl.form.yaml under archive/<party>/, serve the embedded
// default. Mirrors the static-handler fallback for direct YAML
// fetches so the form recognizer and the loader agree on what
// "this spec exists" means.
if os.IsNotExist(err) {
if bytes, ok := IsDefaultMdlSpecAbs(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))
}