Add an internal access-decision boundary that all handlers go through
instead of calling zddc.AllowedWithChain directly. Two implementations
ship:
* InternalDecider — wraps the existing zddc.AllowedWithChain. The
default. No new dependencies, identical semantics to the legacy
code path. ZDDC_OPA_URL=internal (or unset).
* HTTPDecider — POSTs the canonical OPA wire format
(POST /v1/data/zddc/access/allow with {"input": {...}}, response
{"result": true|false}) over HTTP, HTTPS, or a Unix-domain socket.
For federal customers running their own audited Rego policies
alongside zddc-server. ZDDC_OPA_URL=http(s)://… or unix:///….
External-mode failure semantics: unreachable / non-2xx / malformed
response → fail closed (deny) by default with a WARN log. Operators
who prefer availability over correctness flip with ZDDC_OPA_FAIL_OPEN=1.
The decider is constructed once at startup, plumbed through ACLMiddleware
into the request context. Handlers retrieve it via DeciderFromContext;
non-request callers (fs.ListDirectory, EnumerateProjects, enumerateAccess)
take it as an explicit parameter.
zddc.ZddcFile and zddc.ACLRules gain JSON tags so external Rego authors
get idiomatic input shape (acl.allow, admins, …) instead of Go field
names (ACL.Allow, Admins, …).
Test coverage:
* InternalDecider parity tests against zddc.AllowedWithChain (every
documented cascade scenario: empty chain, leaf-allow-wins, leaf-
deny-beats-parent, leaf-allows-what-parent-denies, deepest-match-
wins, etc.)
* HTTPDecider happy-path test (canonical wire format)
* Fail-closed / fail-open / malformed-response tests
Production binary size unchanged (no new deps; HTTP transport is
stdlib net/http). 11 ACL call sites migrated. End-to-end verified
against the worked-example layout in zddc/README.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
579 lines
18 KiB
Go
579 lines
18 KiB
Go
// Package handler — formhandler.go: the form-data system endpoints.
|
|
//
|
|
// URL conventions (the form always POSTs to the same URL it was GET'd from;
|
|
// the server strips ".html" and routes by what's underneath):
|
|
//
|
|
// GET /<path>/<name>.form.html → render empty form
|
|
// POST /<path>/<name>.form.html → create new submission → 201 + Location
|
|
// GET /<path>/<name>/<id>.yaml.html → render form pre-filled from <id>.yaml
|
|
// POST /<path>/<name>/<id>.yaml.html → validate + overwrite that submission → 200
|
|
//
|
|
// Direct GET of the raw .yaml (data) and .form.yaml (spec) continues through
|
|
// the existing static-file path; only the .html suffix is hijacked here.
|
|
//
|
|
// Storage layout: a form named "safety" lives at <dir>/safety.form.yaml;
|
|
// submissions go to <dir>/safety/<YYYY-MM-DD>-<email-sanitized>.yaml. The
|
|
// submissions folder is created lazily on first POST. ACL via the existing
|
|
// .zddc cascade — submit-rights = path-write-rights at the submissions
|
|
// directory.
|
|
package handler
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
//go:embed form.html
|
|
var embeddedFormHTML []byte
|
|
|
|
// FormSpec is the YAML envelope of a <name>.form.yaml file.
|
|
//
|
|
// v0 fields: Title, Description, Schema, UI. Mode is reserved for the v1
|
|
// file-as-truth introduction (default form-as-truth = empty / "form-as-truth").
|
|
// Unknown YAML keys are ignored — this struct is the source of truth for the
|
|
// supported form-spec vocabulary.
|
|
type FormSpec struct {
|
|
Title string `yaml:"title"`
|
|
Description string `yaml:"description"`
|
|
Schema *jsonschema.Schema `yaml:"schema"`
|
|
UI map[string]interface{} `yaml:"ui"`
|
|
Mode string `yaml:"mode"`
|
|
}
|
|
|
|
// formContext is the JSON object the server injects into the form HTML.
|
|
// The renderer (form/js/context.js) reads this from #form-context.
|
|
type formContext struct {
|
|
Title string `json:"title,omitempty"`
|
|
Schema *jsonschema.Schema `json:"schema"`
|
|
UI map[string]interface{} `json:"ui,omitempty"`
|
|
Data interface{} `json:"data,omitempty"`
|
|
SubmitURL string `json:"submitUrl"`
|
|
Errors []jsonschema.Error `json:"errors,omitempty"`
|
|
}
|
|
|
|
// FormRequest describes a recognized form-system request.
|
|
type FormRequest struct {
|
|
// Kind is one of: "render-empty", "create", "render-edit", "update".
|
|
Kind string
|
|
// SpecPath is the absolute filesystem path to the <name>.form.yaml.
|
|
SpecPath string
|
|
// DataPath is the absolute filesystem path to the data .yaml; empty for
|
|
// render-empty / create.
|
|
DataPath string
|
|
// SubmitURL is the URL the form should POST back to (the server-injected
|
|
// "submit to my own URL" value).
|
|
SubmitURL string
|
|
}
|
|
|
|
// RecognizeFormRequest classifies r as a form-system request, or returns nil
|
|
// if it falls through to static file serving. Form-spec existence on disk is
|
|
// required: a *.form.html URL with no corresponding *.form.yaml is not a
|
|
// form request.
|
|
//
|
|
// Methods other than GET / POST return nil (HEAD / OPTIONS pass through to
|
|
// the catch-all so the standard handlers respond).
|
|
func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
|
|
if method != http.MethodGet && method != http.MethodPost {
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(urlPath, ".html") {
|
|
return nil
|
|
}
|
|
underlying := strings.TrimSuffix(urlPath, ".html")
|
|
|
|
// Form-spec URL: <name>.form.html → spec at <name>.form.yaml.
|
|
// Data URL: <name>/<id>.yaml.html → underlying ends in .yaml → spec at
|
|
// <name>.form.yaml at the parent's parent.
|
|
if strings.HasSuffix(underlying, ".form") {
|
|
// <name>.form.html — empty form / create.
|
|
specRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/"))) + ".yaml"
|
|
specAbs := filepath.Join(fsRoot, specRel)
|
|
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
|
|
return nil
|
|
}
|
|
if !fileExists(specAbs) {
|
|
return nil
|
|
}
|
|
kind := "render-empty"
|
|
if method == http.MethodPost {
|
|
kind = "create"
|
|
}
|
|
return &FormRequest{
|
|
Kind: kind,
|
|
SpecPath: specAbs,
|
|
SubmitURL: urlPath,
|
|
}
|
|
}
|
|
|
|
if strings.HasSuffix(underlying, ".yaml") {
|
|
// <name>/<id>.yaml.html — re-edit / update.
|
|
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
|
|
dataAbs := filepath.Join(fsRoot, dataRel)
|
|
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
|
|
return nil
|
|
}
|
|
// Spec lives at the parent's parent: <dir>/<name>/<id>.yaml →
|
|
// <dir>/<name>.form.yaml.
|
|
parentDir := filepath.Dir(dataAbs)
|
|
formName := filepath.Base(parentDir)
|
|
grandparent := filepath.Dir(parentDir)
|
|
specPath := filepath.Join(grandparent, formName+".form.yaml")
|
|
if !fileExists(specPath) {
|
|
return nil
|
|
}
|
|
kind := "render-edit"
|
|
if method == http.MethodPost {
|
|
kind = "update"
|
|
}
|
|
return &FormRequest{
|
|
Kind: kind,
|
|
SpecPath: specPath,
|
|
DataPath: dataAbs,
|
|
SubmitURL: urlPath,
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ServeForm dispatches a recognized form request to render or write logic.
|
|
// The catch-all dispatch in zddc-server/main.go calls this whenever
|
|
// RecognizeFormRequest returns non-nil.
|
|
func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
|
|
switch req.Kind {
|
|
case "render-empty":
|
|
serveFormRender(cfg, req, w, r, nil)
|
|
case "render-edit":
|
|
serveFormRender(cfg, req, w, r, nil)
|
|
case "create":
|
|
serveFormCreate(cfg, req, w, r)
|
|
case "update":
|
|
serveFormUpdate(cfg, req, w, r)
|
|
default:
|
|
http.Error(w, "unknown form request kind", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// serveFormRender handles GET requests for both empty and pre-filled forms.
|
|
// validationErrs is non-nil only when re-rendering after a POST→422 (not used
|
|
// in v0 — POST returns JSON 422 and the client patches errors into the live
|
|
// form via JS).
|
|
func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request, validationErrs []jsonschema.Error) {
|
|
email := EmailFromContext(r)
|
|
|
|
// ACL: read-rights at the directory holding the spec (and, for edits, at
|
|
// the directory holding the data file). Cascade chain is the same for
|
|
// every entity in the same directory — a single check covers both.
|
|
gateDir := filepath.Dir(req.SpecPath)
|
|
if req.DataPath != "" {
|
|
gateDir = filepath.Dir(req.DataPath)
|
|
}
|
|
chain, err := zddc.EffectivePolicy(cfg.Root, gateDir)
|
|
if err != nil {
|
|
slog.Warn("form: policy error", "path", gateDir, "err", err)
|
|
}
|
|
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if len(embeddedFormHTML) == 0 {
|
|
http.Error(w, "form renderer not built into this binary", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
spec, err := loadFormSpec(req.SpecPath)
|
|
if err != nil {
|
|
slog.Warn("form: spec parse error", "path", req.SpecPath, "err", err)
|
|
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var data interface{}
|
|
if req.DataPath != "" {
|
|
if !fileExists(req.DataPath) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
raw, err := os.ReadFile(req.DataPath)
|
|
if err != nil {
|
|
http.Error(w, "read submission: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := yaml.Unmarshal(raw, &data); err != nil {
|
|
http.Error(w, "parse submission: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data = normalizeYAMLForJSON(data)
|
|
}
|
|
|
|
ctx := formContext{
|
|
Title: spec.Title,
|
|
Schema: spec.Schema,
|
|
UI: spec.UI,
|
|
Data: data,
|
|
SubmitURL: req.SubmitURL,
|
|
Errors: validationErrs,
|
|
}
|
|
|
|
html, err := injectFormContext(embeddedFormHTML, ctx)
|
|
if err != nil {
|
|
http.Error(w, "render: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(html)
|
|
}
|
|
|
|
// serveFormCreate handles POST to <name>.form.html — creates a new submission.
|
|
func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
|
|
email := EmailFromContext(r)
|
|
if email == "" {
|
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
formName := strings.TrimSuffix(filepath.Base(req.SpecPath), ".form.yaml")
|
|
specDir := filepath.Dir(req.SpecPath)
|
|
submissionsDir := filepath.Join(specDir, formName)
|
|
|
|
// ACL: write-rights at submissions dir. The dir may not exist yet; the
|
|
// cascade chain falls back to the parent.
|
|
gateDir := submissionsDir
|
|
if !fileExists(submissionsDir) {
|
|
gateDir = specDir
|
|
}
|
|
chain, err := zddc.EffectivePolicy(cfg.Root, gateDir)
|
|
if err != nil {
|
|
slog.Warn("form: policy error", "path", gateDir, "err", err)
|
|
}
|
|
if 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(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(req.SpecPath)
|
|
if err != nil {
|
|
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 {
|
|
writeValidationErrors(w, errs)
|
|
return
|
|
}
|
|
|
|
yamlBytes, err := yaml.Marshal(data)
|
|
if err != nil {
|
|
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := zddc.WriteAtomic(req.DataPath, yamlBytes); err != nil {
|
|
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}
|
|
|
|
// --- Helpers -----------------------------------------------------------------
|
|
|
|
func loadFormSpec(path string) (*FormSpec, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var spec FormSpec
|
|
if err := yaml.Unmarshal(data, &spec); err != nil {
|
|
return nil, fmt.Errorf("parse: %w", err)
|
|
}
|
|
if spec.Schema == nil {
|
|
return nil, errors.New("form spec has no schema")
|
|
}
|
|
return &spec, nil
|
|
}
|
|
|
|
// decodeRequestData reads the request body as JSON (preferred) or YAML,
|
|
// returning the decoded value as the same `any` shape jsonschema.Validate
|
|
// expects. Body size is capped at 1 MiB.
|
|
func decodeRequestData(r *http.Request) (interface{}, error) {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) == 0 {
|
|
return nil, errors.New("empty body")
|
|
}
|
|
ct := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
|
|
if ct == "application/yaml" || ct == "application/x-yaml" || ct == "text/yaml" {
|
|
var v interface{}
|
|
if err := yaml.Unmarshal(body, &v); err != nil {
|
|
return nil, err
|
|
}
|
|
return normalizeYAMLForJSON(v), nil
|
|
}
|
|
var v interface{}
|
|
dec := json.NewDecoder(strings.NewReader(string(body)))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&v); err != nil {
|
|
return nil, err
|
|
}
|
|
return normalizeJSONNumbers(v), nil
|
|
}
|
|
|
|
// normalizeYAMLForJSON converts yaml.v3's `map[interface{}]interface{}` (which
|
|
// it produces for mappings under a generic `interface{}` target) into
|
|
// `map[string]interface{}` so the rest of the pipeline can assume JSON shape.
|
|
// Also recurses through slices.
|
|
func normalizeYAMLForJSON(v interface{}) interface{} {
|
|
switch x := v.(type) {
|
|
case map[interface{}]interface{}:
|
|
out := make(map[string]interface{}, len(x))
|
|
for k, val := range x {
|
|
out[fmt.Sprintf("%v", k)] = normalizeYAMLForJSON(val)
|
|
}
|
|
return out
|
|
case map[string]interface{}:
|
|
out := make(map[string]interface{}, len(x))
|
|
for k, val := range x {
|
|
out[k] = normalizeYAMLForJSON(val)
|
|
}
|
|
return out
|
|
case []interface{}:
|
|
out := make([]interface{}, len(x))
|
|
for i, item := range x {
|
|
out[i] = normalizeYAMLForJSON(item)
|
|
}
|
|
return out
|
|
}
|
|
return v
|
|
}
|
|
|
|
// normalizeJSONNumbers converts json.Number values into int64 (when integral)
|
|
// or float64. Without this, the validator would have to know about
|
|
// json.Number, which would couple the focused jsonschema package to the
|
|
// json decoder we happen to use here.
|
|
func normalizeJSONNumbers(v interface{}) interface{} {
|
|
switch x := v.(type) {
|
|
case json.Number:
|
|
if i, err := x.Int64(); err == nil {
|
|
return i
|
|
}
|
|
if f, err := x.Float64(); err == nil {
|
|
return f
|
|
}
|
|
return x.String()
|
|
case map[string]interface{}:
|
|
for k, val := range x {
|
|
x[k] = normalizeJSONNumbers(val)
|
|
}
|
|
return x
|
|
case []interface{}:
|
|
for i, item := range x {
|
|
x[i] = normalizeJSONNumbers(item)
|
|
}
|
|
return x
|
|
}
|
|
return v
|
|
}
|
|
|
|
func writeValidationErrors(w http.ResponseWriter, errs []jsonschema.Error) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"errors": errs,
|
|
})
|
|
}
|
|
|
|
// sanitizeEmail produces a safe filename component from an email address.
|
|
// "casey@proton.me" → "casey-at-proton-me". Conservative: also flattens any
|
|
// path-meaningful characters so a malicious email can't escape its directory,
|
|
// then collapses runs of '-' and trims leading/trailing '-' so the resulting
|
|
// filename is well-formed.
|
|
func sanitizeEmail(s string) string {
|
|
s = strings.ReplaceAll(s, "@", "-at-")
|
|
s = strings.ReplaceAll(s, ".", "-")
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z',
|
|
r >= 'A' && r <= 'Z',
|
|
r >= '0' && r <= '9',
|
|
r == '-', r == '_':
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
out := b.String()
|
|
for strings.Contains(out, "--") {
|
|
out = strings.ReplaceAll(out, "--", "-")
|
|
}
|
|
out = strings.Trim(out, "-")
|
|
if out == "" {
|
|
out = "anonymous"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pickAvailableFilename tries `<base><ext>`, then `<base>-2<ext>`, ...,
|
|
// `<base>-100<ext>`, returning the first that does not yet exist on disk.
|
|
func pickAvailableFilename(dir, base, ext string) (path, name string, ok bool) {
|
|
name = base + ext
|
|
path = filepath.Join(dir, name)
|
|
if !fileExists(path) {
|
|
return path, name, true
|
|
}
|
|
for i := 2; i < 100; i++ {
|
|
name = base + "-" + strconv.Itoa(i) + ext
|
|
path = filepath.Join(dir, name)
|
|
if !fileExists(path) {
|
|
return path, name, true
|
|
}
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// injectFormContext rewrites the embedded form HTML's #form-context placeholder
|
|
// with a serialized form context. Defends against script-tag breakouts in the
|
|
// JSON values by escaping any "</" sequences as "<\\/" — the JS engine treats
|
|
// a backslash-escaped slash identically inside a string literal, so behavior
|
|
// is preserved while the HTML parser cannot mistake content for a closing
|
|
// </script>.
|
|
func injectFormContext(template []byte, ctx formContext) ([]byte, error) {
|
|
js, err := json.Marshal(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
js = []byte(strings.ReplaceAll(string(js), "</", "<\\/"))
|
|
|
|
needle := []byte(`<script id="form-context" type="application/json">{}</script>`)
|
|
if !bytesContains(template, needle) {
|
|
return nil, errors.New("#form-context placeholder not found in template")
|
|
}
|
|
replacement := append([]byte(`<script id="form-context" type="application/json">`), js...)
|
|
replacement = append(replacement, []byte(`</script>`)...)
|
|
|
|
out := bytesReplace(template, needle, replacement)
|
|
return out, nil
|
|
}
|
|
|
|
// Tiny bytes helpers — scoped here so we don't pull in "bytes" for two calls.
|
|
func bytesContains(haystack, needle []byte) bool {
|
|
return strings.Contains(string(haystack), string(needle))
|
|
}
|
|
|
|
func bytesReplace(haystack, needle, replacement []byte) []byte {
|
|
return []byte(strings.Replace(string(haystack), string(needle), string(replacement), 1))
|
|
}
|