|
|
@@ -0,0 +1,215 @@
|
|
|
+# Form Trigger - Design
|
|
|
+
|
|
|
+Date: 2026-08-09
|
|
|
+
|
|
|
+## Goal
|
|
|
+
|
|
|
+A workflow can publish an HTML form at a shareable URL. Somebody with the link
|
|
|
+fills it in - text, choices, a file - and the submission starts the workflow
|
|
|
+with the answers as trigger data.
|
|
|
+
|
|
|
+The immediate use is the anime pipeline: today an image only enters it from an
|
|
|
+RSS feed, and there is no way to hand it one by hand. The wider use is any
|
|
|
+form a person outside SmartBotic should be able to answer, the way Google Forms
|
|
|
+or n8n's Form Trigger are used.
|
|
|
+
|
|
|
+## What already exists
|
|
|
+
|
|
|
+Checked against the code before this design was written, because most of the
|
|
|
+public surface is already built.
|
|
|
+
|
|
|
+**The public route is there.** `webhook_controller.cpp:33` registers
|
|
|
+`/webhook/{workflowId}{path}` for GET, POST, PUT, DELETE and PATCH. It resolves
|
|
|
+the workflow, refuses one that is not active, runs the **published** version
|
|
|
+(`use_published(true)`), and returns the result as the HTTP body. A
|
|
|
+`respond-to-webhook` node can already set status, headers and body through the
|
|
|
+`_webhookResponse` key.
|
|
|
+
|
|
|
+**Binary already has a shape.** `http-request.js:457` produces
|
|
|
+`{type:'binary', data, mimeType, filename, size, checksum}`, and every node that
|
|
|
+takes an image takes that. A form upload that emits the same object needs no
|
|
|
+downstream change at all - which is the whole reason an uploaded image can walk
|
|
|
+the same path as an RSS one.
|
|
|
+
|
|
|
+**Trigger matching is by method.** `findTriggerNode` maps GET to `get-trigger`,
|
|
|
+POST to `post-trigger`, PUT to `put-trigger` (`webhook_controller.cpp:311`).
|
|
|
+
|
|
|
+## What is missing
|
|
|
+
|
|
|
+**Multipart is not parsed.** `webhook_controller.cpp:135` parses the body as
|
|
|
+JSON and, failing that, keeps it as a `std::string`. An uploaded PNG would
|
|
|
+arrive as a string that is not valid UTF-8. httplib 0.18.3 does expose
|
|
|
+`req.files` and `is_multipart_form_data()`, so this is wiring rather than
|
|
|
+invention.
|
|
|
+
|
|
|
+**Uploads are unbounded.** `CPPHTTPLIB_PAYLOAD_MAX_LENGTH` is
|
|
|
+`(std::numeric_limits<size_t>::max)()` by default and the body is buffered whole
|
|
|
+in memory. Every existing public webhook is therefore a memory-exhaustion
|
|
|
+endpoint, not only the new form ones. This design fixes that because it must,
|
|
|
+not because forms introduced it.
|
|
|
+
|
|
|
+**One node must answer two verbs.** A form is a GET that renders and a POST that
|
|
|
+submits, so the method mapping above cannot express it.
|
|
|
+
|
|
|
+**The webhook path always waits.** `wait_for_completion(true)` with a 30 second
|
|
|
+timeout. The anime pipeline runs for minutes, so a form that waits would time
|
|
|
+out on every submission.
|
|
|
+
|
|
|
+## Part 1 - The `form-trigger` node
|
|
|
+
|
|
|
+`nodes/triggers/form-trigger.js`. Config:
|
|
|
+
|
|
|
+| Key | Meaning |
|
|
|
+| --- | --- |
|
|
|
+| `title`, `description` | Shown at the top of the page |
|
|
|
+| `fields[]` | The form, see below |
|
|
|
+| `password` | Optional. Empty means anyone with the link |
|
|
|
+| `responseMode` | `immediate` or `wait` |
|
|
|
+| `responseMessage` | Shown after submit in `immediate` mode |
|
|
|
+| `path` | Optional suffix, as the other HTTP triggers have |
|
|
|
+
|
|
|
+A field is `{name, label, type, required, placeholder, options[], accept,
|
|
|
+maxSizeMb}`. Types: `text`, `textarea`, `number`, `select`, `checkbox`, `date`,
|
|
|
+`file`.
|
|
|
+
|
|
|
+`name` is the key the workflow reads, so it is restricted to
|
|
|
+`[A-Za-z_][A-Za-z0-9_]*` and must be unique within the form. A duplicate or
|
|
|
+malformed name is a validation error when the workflow is saved, not a surprise
|
|
|
+at submission time.
|
|
|
+
|
|
|
+Trigger data:
|
|
|
+
|
|
|
+```js
|
|
|
+{
|
|
|
+ form: {
|
|
|
+ title: "A quiet street",
|
|
|
+ image: { type: 'binary', data, mimeType, filename, size, checksum }
|
|
|
+ },
|
|
|
+ submittedAt: 1786300000000,
|
|
|
+ clientIp: "192.168.2.31"
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Files sit in `form.<fieldName>` beside the text answers rather than in a
|
|
|
+separate `files` map. One namespace, so `$node['Form'].form.image` is the answer
|
|
|
+whatever the field's type.
|
|
|
+
|
|
|
+## Part 2 - Serving the page
|
|
|
+
|
|
|
+In `webhook_controller.cpp`, a form-trigger lookup runs **before** the method
|
|
|
+mapping, since one node answers both verbs.
|
|
|
+
|
|
|
+- **GET** renders the form. If `password` is set and the request carries no
|
|
|
+ valid form cookie, it renders a password prompt instead (see Part 4).
|
|
|
+- **POST** parses `multipart/form-data` (or `application/x-www-form-urlencoded`
|
|
|
+ when the form has no file field), validates, builds the trigger data above,
|
|
|
+ and dispatches.
|
|
|
+
|
|
|
+The HTML is generated server-side and is self-contained: no external stylesheet,
|
|
|
+font or script. That keeps a form working on a network that cannot reach a CDN,
|
|
|
+and keeps the page's behaviour a property of this repo.
|
|
|
+
|
|
|
+**Escaping is the security boundary of this feature.** The form definition is
|
|
|
+authored by a SmartBotic user and the page is served to the public, so every
|
|
|
+interpolated value - title, description, label, placeholder, option, error text,
|
|
|
+previously entered value - is HTML-escaped on the way out. A single unescaped
|
|
|
+label is stored XSS on a public URL.
|
|
|
+
|
|
|
+## Part 3 - Upload limits
|
|
|
+
|
|
|
+`set_payload_max_length()` on the server, bounded and configurable in
|
|
|
+`webserver.json` (default 32 MB). Per-field `maxSizeMb` is checked after
|
|
|
+parsing, and an over-large upload is refused with 413 and a message naming the
|
|
|
+field and the limit.
|
|
|
+
|
|
|
+This applies to every webhook, not only forms.
|
|
|
+
|
|
|
+## Part 4 - Access
|
|
|
+
|
|
|
+`password` empty means anyone with the link, which is the point of a shareable
|
|
|
+form. When set, the GET renders a prompt and the POST requires it.
|
|
|
+
|
|
|
+**It is stored readable in the workflow document**, so anyone who can read the
|
|
|
+workflow can read it. It is a gate against a link that got forwarded, not a
|
|
|
+security boundary, and the node's description will say exactly that rather than
|
|
|
+implying more. Moving it behind a credential reference is a later change if it
|
|
|
+turns out to matter.
|
|
|
+
|
|
|
+Answering the prompt correctly sets a cookie scoped to that form's path,
|
|
|
+holding an HMAC of the form id and an expiry signed with the server's JWT
|
|
|
+secret, valid for one hour. It is a signed token rather than the password
|
|
|
+itself, so the password never sits in the browser and a stolen cookie opens one
|
|
|
+form until it expires. The GET and the POST both accept it, which is what stops
|
|
|
+the prompt appearing twice for one submission.
|
|
|
+
|
|
|
+Comparison is constant-time, and a wrong password re-renders the prompt with a
|
|
|
+generic message - not "wrong password for form X" - so the endpoint does not
|
|
|
+confirm that a form exists at that URL.
|
|
|
+
|
|
|
+## Part 5 - Response modes
|
|
|
+
|
|
|
+**`immediate`** dispatches with `wait_for_completion(false)` and returns the
|
|
|
+thank-you page at once. Required for anything long: the anime pipeline runs for
|
|
|
+minutes against a 30 second webhook timeout.
|
|
|
+
|
|
|
+The scheduler slot accounting in `handleWebhook` assumes the call blocks until
|
|
|
+the run ends (`notifyExecutionStarted` before, release on failure after). The
|
|
|
+non-waiting path must not hold a slot it will never release. It does not have
|
|
|
+to invent anything: the runner already reports back to
|
|
|
+`/api/v1/internal/execution-event`, and `execution_controller.cpp:632` releases
|
|
|
+the slot on `execution.completed`, `.failed`, `.cancelled` and `.waiting`. That
|
|
|
+is precisely how scheduled dispatch, which is fire-and-forget today, gets its
|
|
|
+slot back. `immediate` mode claims the slot the same way and lets that path
|
|
|
+free it.
|
|
|
+
|
|
|
+**`wait`** keeps today's behaviour, so a `respond-to-webhook` node can render a
|
|
|
+real answer for forms that ask a question rather than start a job.
|
|
|
+
|
|
|
+## Part 6 - The anime variation
|
|
|
+
|
|
|
+The ten nodes from `Vision Analyse` to `Publish To Blog` were checked for
|
|
|
+references outside themselves: `$node[...]` references, `data.loop.*` variables,
|
|
|
+and any mention of the feed. There are none. The chain is self-contained and its
|
|
|
+only input is the image.
|
|
|
+
|
|
|
+- New sub-workflow `[SWF] anime: process one image`, input `file` (binary),
|
|
|
+ containing those ten nodes unchanged.
|
|
|
+- `35photo2anime - sdcpp` loop body becomes `Fetch Image → call-workflow`.
|
|
|
+- New workflow `anime from upload`: `form-trigger → call-workflow`, same target.
|
|
|
+
|
|
|
+Both paths then share one copy. A rubric fix like the 18+ one lands in both at
|
|
|
+once, instead of in whichever copy somebody remembered.
|
|
|
+
|
|
|
+**Order matters.** Build the sub-workflow and the form path first and verify an
|
|
|
+upload end to end; switch the RSS loop over last, once the shared copy is proven
|
|
|
+by a real run. The working path changes only after the new one works.
|
|
|
+
|
|
|
+## Part 7 - WebUI
|
|
|
+
|
|
|
+- A field-row editor for `fields[]`, following `ArrayFieldEditor.tsx`, which
|
|
|
+ already edits an array of objects for other nodes.
|
|
|
+- The form's URL shown on the node and copyable, since a URL nobody can find is
|
|
|
+ not shareable.
|
|
|
+
|
|
|
+## Out of scope, deliberately
|
|
|
+
|
|
|
+- No form designer, no layout control, no theming beyond the built-in stylesheet.
|
|
|
+- No multi-page forms and no conditional fields.
|
|
|
+- No captcha. A public form with no captcha can be submitted repeatedly, and for
|
|
|
+ the anime form that means posts on a live blog - so that form ships with a
|
|
|
+ password set. Worth revisiting if forms are used more widely.
|
|
|
+- No submissions view. A submission is an execution and is visible as one.
|
|
|
+- No file-type enforcement beyond the `accept` attribute, which is a browser
|
|
|
+ hint and not a check. A form that must not receive an executable needs a node
|
|
|
+ that looks at the bytes.
|
|
|
+
|
|
|
+## Verification
|
|
|
+
|
|
|
+- Node tests for field validation: required, name format, duplicate names,
|
|
|
+ unknown type, size limit.
|
|
|
+- A test that an over-large body is refused with 413 rather than buffered.
|
|
|
+- Escaping test: a form whose title is `<script>alert(1)</script>` renders it as
|
|
|
+ text.
|
|
|
+- End to end: submit a real image through the form and confirm the run reaches
|
|
|
+ the same sub-workflow the RSS path reaches, producing a post.
|
|
|
+- The RSS path re-run after the switch, confirming it still produces a post.
|