|
@@ -0,0 +1,1443 @@
|
|
|
|
|
+# Form Trigger Implementation Plan
|
|
|
|
|
+
|
|
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
|
+
|
|
|
|
|
+**Goal:** A workflow can publish an HTML form at a shareable URL; submitting it - including a file upload - starts the workflow with the answers as trigger data.
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** A new `form-trigger` node carries the form definition. `WebhookController` gains a form branch that runs *before* its HTTP-method-to-node-type mapping, because one node must answer both GET (render the page) and POST (accept the submission). Uploaded files are converted to the same `{type:'binary', ...}` object `http-request` already produces, so existing image nodes accept them unchanged. Separately, the ten self-contained nodes of the anime pipeline are extracted into a sub-workflow that both the RSS loop and a new upload form call.
|
|
|
|
|
+
|
|
|
|
|
+**Tech Stack:** C++20, cpp-httplib 0.18.3 (`req.files`), OpenSSL HMAC (already used by `JwtUtils`), nlohmann/json, QuickJS nodes in `nodes/`, React/TypeScript WebUI.
|
|
|
|
|
+
|
|
|
|
|
+## Global Constraints
|
|
|
|
|
+
|
|
|
|
|
+- Spec: `docs/superpowers/specs/2026-08-09-form-trigger-design.md`. Read it before Task 1.
|
|
|
|
|
+- C++20 strict, warnings `-Wall -Wextra -Wpedantic` must stay clean.
|
|
|
|
|
+- **Never use em-dashes or en-dashes** in any output: code, comments, commits, log strings, HTML copy. ASCII hyphens only.
|
|
|
|
|
+- Node tests are JSON cases in `tests/nodes/*.json`, run by `./scripts/run-node-tests.sh` against **live services**. The full suite must be green before any commit. It was 70 passed / 0 failed at the start of this work.
|
|
|
|
|
+- Rebuild with `cmake --build build -j$(nproc)`; restart with `systemctl --user restart smartbotic-webserver` (and `smartbotic-runner` when node files change).
|
|
|
|
|
+- After editing anything in `nodes/`, migrate it: see "Migrating Nodes to Database" in `CLAUDE.md`.
|
|
|
|
|
+- Uploaded-file objects must match `http-request.js:457` exactly: `{type, data, mimeType, filename, size, checksum}` where `data` is base64 and `checksum` is `sha256(base64Data)`.
|
|
|
|
|
+- Field `name` values are restricted to `^[A-Za-z_][A-Za-z0-9_]*$` and must be unique within one form.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 1: The `form-trigger` node definition
|
|
|
|
|
+
|
|
|
|
|
+Declares the form. No serving yet - this task is the schema and the trigger-data mapping, so the WebUI can render an editor and the controller has something to read.
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `nodes/triggers/form-trigger.js`
|
|
|
|
|
+- Test: `tests/nodes/form-trigger-shape.json`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: nothing.
|
|
|
|
|
+- Produces: node type `form-trigger` whose `config` is `{title, description, fields[], password, responseMode, responseMessage, path}`. Each entry of `fields[]` is `{name, label, type, required, placeholder, options, accept, maxSizeMb}`. `execute()` returns `{form, submittedAt, clientIp}`. Task 3 reads this config; Task 8 reads the returned shape.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+`tests/nodes/form-trigger-shape.json` - the node must pass the submitted form through untouched when the controller supplies trigger data. Executing it directly (no webhook) means `context.triggerData` is empty, which must yield an empty form rather than an error:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "name": "verify-form-trigger-shape",
|
|
|
|
|
+ "nodes": [
|
|
|
|
|
+ {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
|
|
|
|
|
+ "config": {"title": "Send an image",
|
|
|
|
|
+ "fields": [{"name": "image", "label": "Image", "type": "file", "required": true}]}},
|
|
|
|
|
+ {"id": "n2", "name": "Check", "type": "code", "position": {"x": 0, "y": 100},
|
|
|
|
|
+ "config": {"code": "return { hasForm: typeof data.form === 'object' && data.form !== null, submitted: typeof data.submittedAt === 'number' };"}}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "connections": [
|
|
|
|
|
+ {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "expect": {
|
|
|
|
|
+ "n2": {"status": "completed", "output": {"result": {"hasForm": true, "submitted": true}}}
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run it and watch it fail**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-trigger-shape.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: FAIL - the node type `form-trigger` does not exist.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write the node**
|
|
|
|
|
+
|
|
|
|
|
+`nodes/triggers/form-trigger.js`. Follow the header-comment convention in `nodes/triggers/post-trigger.js` exactly - the `@node`, `@name`, `@category`, `@trigger` tags are how it is discovered.
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+/**
|
|
|
|
|
+ * @node form-trigger
|
|
|
|
|
+ * @name Form
|
|
|
|
|
+ * @category triggers
|
|
|
|
|
+ * @version 1.0.0
|
|
|
|
|
+ * @description Publishes an HTML form at a shareable URL. Submitting it starts the workflow.
|
|
|
|
|
+ * @trigger
|
|
|
|
|
+ * @icon clipboard-list
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+const configSchema = {
|
|
|
|
|
+ type: 'object',
|
|
|
|
|
+ properties: {
|
|
|
|
|
+ title: {
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ title: 'Form Title',
|
|
|
|
|
+ description: 'Shown as the heading of the page',
|
|
|
|
|
+ default: 'Untitled form'
|
|
|
|
|
+ },
|
|
|
|
|
+ description: {
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ title: 'Description',
|
|
|
|
|
+ description: 'Shown under the heading. Plain text - it is escaped, not rendered as HTML.',
|
|
|
|
|
+ default: ''
|
|
|
|
|
+ },
|
|
|
|
|
+ fields: {
|
|
|
|
|
+ type: 'array',
|
|
|
|
|
+ title: 'Fields',
|
|
|
|
|
+ description: 'The questions on the form, in order.',
|
|
|
|
|
+ default: [],
|
|
|
|
|
+ items: {
|
|
|
|
|
+ type: 'object',
|
|
|
|
|
+ properties: {
|
|
|
|
|
+ name: { type: 'string', title: 'Name',
|
|
|
|
|
+ description: 'The key the workflow reads, e.g. "image" for $node[\'Form\'].form.image. Letters, digits and underscore, not starting with a digit.' },
|
|
|
|
|
+ label: { type: 'string', title: 'Label', description: 'Shown next to the input' },
|
|
|
|
|
+ type: { type: 'string', title: 'Type',
|
|
|
|
|
+ enum: ['text', 'textarea', 'number', 'select', 'checkbox', 'date', 'file'],
|
|
|
|
|
+ default: 'text' },
|
|
|
|
|
+ required: { type: 'boolean', title: 'Required', default: false },
|
|
|
|
|
+ placeholder: { type: 'string', title: 'Placeholder', default: '' },
|
|
|
|
|
+ options: { type: 'array', title: 'Options', description: 'Choices, for a Select field',
|
|
|
|
|
+ items: { type: 'string' }, default: [] },
|
|
|
|
|
+ accept: { type: 'string', title: 'Accept',
|
|
|
|
|
+ description: 'For a File field, e.g. "image/*". A browser hint only - it is not enforced.',
|
|
|
|
|
+ default: '' },
|
|
|
|
|
+ maxSizeMb: { type: 'number', title: 'Max Size (MB)',
|
|
|
|
|
+ description: 'For a File field. 0 uses the server limit.', default: 0 }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ password: {
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ title: 'Password',
|
|
|
|
|
+ format: 'password',
|
|
|
|
|
+ description: 'Leave empty for anyone with the link. This is stored readable in the workflow, so treat it as a gate against a forwarded link, not as a secret.',
|
|
|
|
|
+ default: ''
|
|
|
|
|
+ },
|
|
|
|
|
+ responseMode: {
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ title: 'After Submit',
|
|
|
|
|
+ enum: ['immediate', 'wait'],
|
|
|
|
|
+ description: 'Immediate returns a thank-you page at once and lets the run continue - needed for anything taking more than about 30 seconds. Wait holds the browser until the workflow finishes, so a Respond to Webhook node can answer.',
|
|
|
|
|
+ default: 'immediate'
|
|
|
|
|
+ },
|
|
|
|
|
+ responseMessage: {
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ title: 'Thank-you Message',
|
|
|
|
|
+ default: 'Thanks, your answer was received.',
|
|
|
|
|
+ 'x-if': 'responseMode'
|
|
|
|
|
+ },
|
|
|
|
|
+ path: {
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ title: 'Path Filter',
|
|
|
|
|
+ description: 'Optional path suffix. Leave empty to answer at the bare form URL.',
|
|
|
|
|
+ default: ''
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const inputSchema = { type: 'object', properties: {} };
|
|
|
|
|
+
|
|
|
|
|
+const outputSchema = {
|
|
|
|
|
+ type: 'object',
|
|
|
|
|
+ properties: {
|
|
|
|
|
+ form: { type: 'object', description: 'The answers, keyed by field name. A file field holds a binary object.' },
|
|
|
|
|
+ submittedAt: { type: 'number', description: 'Milliseconds since the epoch' },
|
|
|
|
|
+ clientIp: { type: 'string' }
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+async function execute(config, input, context) {
|
|
|
|
|
+ // Every value here is built by the webhook controller, which has already
|
|
|
|
|
+ // validated it. Executed directly - from the editor's Run button - there is
|
|
|
|
|
+ // no submission, and an empty form is the honest answer rather than an error.
|
|
|
|
|
+ const triggerData = context.triggerData || {};
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ form: triggerData.form || {},
|
|
|
|
|
+ submittedAt: triggerData.submittedAt || Date.now(),
|
|
|
|
|
+ clientIp: triggerData.clientIp || ''
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+module.exports = { configSchema, inputSchema, outputSchema, execute };
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Migrate the node and re-run the test**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+TOKEN=$(curl -s http://localhost:8090/api/v1/auth/login -H "Content-Type: application/json" \
|
|
|
|
|
+ -d '{"username": "admin", "password": "admin"}' | jq -r '.accessToken')
|
|
|
|
|
+curl -X POST http://localhost:8090/api/v1/nodes/migrate -H "Authorization: Bearer $TOKEN" \
|
|
|
|
|
+ -H "Content-Type: application/json" -d '{"nodesPath": "./nodes"}'
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-trigger-shape.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: PASS.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Run the whole suite and commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+./scripts/run-node-tests.sh
|
|
|
|
|
+git add nodes/triggers/form-trigger.js tests/nodes/form-trigger-shape.json
|
|
|
|
|
+git commit -m "feat: a form-trigger node describing a form and its answers"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 2: Cap the request body
|
|
|
|
|
+
|
|
|
|
|
+Do this before anything serves an upload. `CPPHTTPLIB_PAYLOAD_MAX_LENGTH` is `SIZE_MAX` and bodies buffer whole in memory, so every public webhook is currently a way to exhaust the webserver. This is a standalone fix and is worth its own commit.
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `src/webserver/webserver_service.cpp` (config load near line 195, and where the `httplib::Server` is configured)
|
|
|
|
|
+- Modify: `config/webserver.json`
|
|
|
|
|
+- Test: `tests/nodes/webhook-body-too-large.json`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: nothing.
|
|
|
|
|
+- Produces: config key `server.max_upload_mb` (integer, default 32), applied via `server.set_payload_max_length()`. Task 3 reads the same value to report a per-field limit.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+`tests/nodes/webhook-body-too-large.json`. The harness sends a JSON body, so a large string is enough to cross the cap:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "name": "verify-webhook-body-too-large",
|
|
|
|
|
+ "requires": {"note": "expects server.max_upload_mb to be set to 1 for this case"},
|
|
|
|
|
+ "nodes": [
|
|
|
|
|
+ {"id": "n1", "name": "Webhook", "type": "post-trigger", "position": {"x": 0, "y": 0}, "config": {}}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "connections": [],
|
|
|
|
|
+ "http": {
|
|
|
|
|
+ "method": "POST",
|
|
|
|
|
+ "path": "/webhook/{workflowId}",
|
|
|
|
|
+ "bodyPadBytes": 2097152,
|
|
|
|
|
+ "expectStatus": 413
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`bodyPadBytes` does not exist yet. Add it to `run_http_case` in `scripts/verify-node.py`, right after the `data = json.dumps(...)` line:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ body = spec.get("body", {})
|
|
|
|
|
+ pad = spec.get("bodyPadBytes", 0)
|
|
|
|
|
+ if pad:
|
|
|
|
|
+ body = dict(body)
|
|
|
|
|
+ body["_pad"] = "x" * pad
|
|
|
|
|
+ data = json.dumps(body).encode()
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run it and watch it fail**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/webhook-body-too-large.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: FAIL - the oversized body is accepted, giving 200 or 500 rather than 413.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Read the limit from config**
|
|
|
|
|
+
|
|
|
|
|
+In `src/webserver/webserver_service.cpp`, beside the other `cfg.getOr` calls around line 195:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ // A public webhook takes a body into memory whole, and httplib's
|
|
|
|
|
+ // default ceiling is SIZE_MAX - so without this any URL under /webhook
|
|
|
|
|
+ // is a way to exhaust the process from off the network.
|
|
|
|
|
+ config.max_upload_mb = cfg.getOr<int>("server.max_upload_mb", 32);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Add `int max_upload_mb = 32;` to the config struct that surrounds it, and where the `httplib::Server` is constructed:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ server.set_payload_max_length(static_cast<size_t>(config.max_upload_mb) * 1024 * 1024);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Add to `config/webserver.json` under `server`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+ "max_upload_mb": 32
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Rebuild, restart, and verify both directions**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cmake --build build -j$(nproc)
|
|
|
|
|
+```
|
|
|
|
|
+Temporarily set `max_upload_mb` to 1 in `config/webserver.json`, restart, then:
|
|
|
|
|
+```bash
|
|
|
|
|
+systemctl --user restart smartbotic-webserver && sleep 5
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/webhook-body-too-large.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: PASS (413).
|
|
|
|
|
+
|
|
|
|
|
+Now check the limit does not reject what it should accept - a cap that rejects everything would pass the test above while breaking every webhook:
|
|
|
|
|
+```bash
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/respond-to-webhook.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: PASS. Then restore `max_upload_mb` to 32 and restart.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Run the whole suite and commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+./scripts/run-node-tests.sh
|
|
|
|
|
+git add src/webserver/webserver_service.cpp config/webserver.json scripts/verify-node.py tests/nodes/webhook-body-too-large.json
|
|
|
|
|
+git commit -m "fix: bound the request body a webhook will buffer"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 3: Render the form on GET
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `src/webserver/api/form_renderer.hpp`, `src/webserver/api/form_renderer.cpp`
|
|
|
|
|
+- Modify: `CMakeLists.txt` (add `form_renderer.cpp` to the webserver sources)
|
|
|
|
|
+- Modify: `src/webserver/api/webhook_controller.cpp` (`handleWebhook`, `findTriggerNode`), `src/webserver/api/webhook_controller.hpp`
|
|
|
|
|
+- Test: `tests/nodes/form-render.json`, `tests/nodes/form-render-escaping.json`
|
|
|
|
|
+
|
|
|
|
|
+Rendering lives in its own file rather than growing `webhook_controller.cpp` (486 lines already): HTML generation and escaping are one responsibility, and they are the part worth reading closely.
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: the `form-trigger` config from Task 1; `max_upload_mb` from Task 2.
|
|
|
|
|
+- Produces:
|
|
|
|
|
+ ```cpp
|
|
|
|
|
+ namespace smartbotic::webserver::api::form_renderer {
|
|
|
|
|
+ std::string escapeHtml(const std::string& raw);
|
|
|
|
|
+ std::string renderForm(const nlohmann::json& config, const std::string& action_url,
|
|
|
|
|
+ const std::string& error_message);
|
|
|
|
|
+ std::string renderPasswordPrompt(const std::string& action_url, const std::string& error_message);
|
|
|
|
|
+ std::string renderMessage(const std::string& title, const std::string& message);
|
|
|
|
|
+ }
|
|
|
|
|
+ ```
|
|
|
|
|
+ Task 4 calls `renderPasswordPrompt`; Task 5 calls `renderMessage`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing tests**
|
|
|
|
|
+
|
|
|
|
|
+`tests/nodes/form-render.json` - a GET returns an HTML page carrying the fields:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "name": "verify-form-render",
|
|
|
|
|
+ "nodes": [
|
|
|
|
|
+ {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
|
|
|
|
|
+ "config": {"title": "Send an image", "description": "Pick a picture",
|
|
|
|
|
+ "fields": [
|
|
|
|
|
+ {"name": "caption", "label": "Caption", "type": "text", "required": true},
|
|
|
|
|
+ {"name": "image", "label": "Image", "type": "file", "required": true, "accept": "image/*"}
|
|
|
|
|
+ ]}}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "connections": [],
|
|
|
|
|
+ "http": {
|
|
|
|
|
+ "method": "GET",
|
|
|
|
|
+ "path": "/webhook/{workflowId}",
|
|
|
|
|
+ "expectStatus": 200,
|
|
|
|
|
+ "expectHeaders": {"Content-Type": "text/html"},
|
|
|
|
|
+ "expectBodyContains": [
|
|
|
|
|
+ "Send an image", "Pick a picture",
|
|
|
|
|
+ "name=\"caption\"", "name=\"image\"",
|
|
|
|
|
+ "type=\"file\"", "accept=\"image/*\"",
|
|
|
|
|
+ "enctype=\"multipart/form-data\""
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`tests/nodes/form-render-escaping.json` - a form definition is user-authored and the page is public, so a title containing markup must arrive as text:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "name": "verify-form-render-escaping",
|
|
|
|
|
+ "nodes": [
|
|
|
|
|
+ {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
|
|
|
|
|
+ "config": {"title": "<script>alert(1)</script>",
|
|
|
|
|
+ "description": "a \"quoted\" & <b>bold</b> description",
|
|
|
|
|
+ "fields": [{"name": "q", "label": "<img src=x onerror=alert(2)>", "type": "text"}]}}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "connections": [],
|
|
|
|
|
+ "http": {
|
|
|
|
|
+ "method": "GET",
|
|
|
|
|
+ "path": "/webhook/{workflowId}",
|
|
|
|
|
+ "expectStatus": 200,
|
|
|
|
|
+ "expectBodyContains": [
|
|
|
|
|
+ "<script>alert(1)</script>",
|
|
|
|
|
+ "<img src=x onerror=alert(2)>",
|
|
|
|
|
+ "&"
|
|
|
|
|
+ ],
|
|
|
|
|
+ "expectBodyExcludes": ["<script>alert(1)</script>", "<img src=x onerror"]
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`expectBodyExcludes` does not exist yet. Add it to `run_http_case` in `scripts/verify-node.py`, after the `expectBodyContains` loop:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ for fragment in spec.get("expectBodyExcludes", []):
|
|
|
|
|
+ if fragment in raw:
|
|
|
|
|
+ failures.append("body: expected NOT to contain %r" % fragment)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+The harness also always sends a JSON body and a Content-Type. Make it skip both when there is no body, so a GET is a real GET:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ method = spec.get("method", "POST")
|
|
|
|
|
+ data = None
|
|
|
|
|
+ if method in ("POST", "PUT", "PATCH"):
|
|
|
|
|
+ ...existing body construction...
|
|
|
|
|
+ req = urllib.request.Request(url, data=data, method=method)
|
|
|
|
|
+ if data is not None:
|
|
|
|
|
+ req.add_header("Content-Type", "application/json")
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run them and watch them fail**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-render.json
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-render-escaping.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: both FAIL - a GET currently finds no `get-trigger` node and runs the workflow instead of rendering.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write the renderer**
|
|
|
|
|
+
|
|
|
|
|
+`src/webserver/api/form_renderer.hpp`:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#pragma once
|
|
|
|
|
+
|
|
|
|
|
+#include <nlohmann/json.hpp>
|
|
|
|
|
+#include <string>
|
|
|
|
|
+
|
|
|
|
|
+namespace smartbotic::webserver::api::form_renderer {
|
|
|
|
|
+
|
|
|
|
|
+/// Every value interpolated into a form page passes through here.
|
|
|
|
|
+///
|
|
|
|
|
+/// The form definition is written by a SmartBotic user and the page is served
|
|
|
|
|
+/// to anybody with the link, so an unescaped label is stored XSS on a public
|
|
|
|
|
+/// URL. This is the boundary; there is no other.
|
|
|
|
|
+std::string escapeHtml(const std::string& raw);
|
|
|
|
|
+
|
|
|
|
|
+/// The form itself. `error_message` is shown above the fields when a submission
|
|
|
|
|
+/// came back invalid, and is empty on a first view.
|
|
|
|
|
+std::string renderForm(const nlohmann::json& config, const std::string& action_url,
|
|
|
|
|
+ const std::string& error_message);
|
|
|
|
|
+
|
|
|
|
|
+/// Shown instead of the form when the form has a password and the request has
|
|
|
|
|
+/// no valid cookie.
|
|
|
|
|
+std::string renderPasswordPrompt(const std::string& action_url, const std::string& error_message);
|
|
|
|
|
+
|
|
|
|
|
+/// A plain page - the thank-you after a submission, or a refusal.
|
|
|
|
|
+std::string renderMessage(const std::string& title, const std::string& message);
|
|
|
|
|
+
|
|
|
|
|
+} // namespace smartbotic::webserver::api::form_renderer
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`src/webserver/api/form_renderer.cpp`:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "form_renderer.hpp"
|
|
|
|
|
+
|
|
|
|
|
+#include <sstream>
|
|
|
|
|
+
|
|
|
|
|
+namespace smartbotic::webserver::api::form_renderer {
|
|
|
|
|
+
|
|
|
|
|
+namespace {
|
|
|
|
|
+
|
|
|
|
|
+// Self-contained on purpose: no CDN stylesheet, no webfont, no script. A form
|
|
|
|
|
+// has to work on a network that cannot reach the internet, and its behaviour
|
|
|
|
|
+// should be a property of this repository rather than of somebody else's host.
|
|
|
|
|
+constexpr const char* kStyle = R"(
|
|
|
|
|
+body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #f6f7f9;
|
|
|
|
|
+ margin: 0; padding: 2rem 1rem; color: #1f2328; }
|
|
|
|
|
+.card { max-width: 34rem; margin: 0 auto; background: #fff; border-radius: 10px;
|
|
|
|
|
+ padding: 1.75rem; box-shadow: 0 1px 3px rgba(0,0,0,.12); }
|
|
|
|
|
+h1 { font-size: 1.35rem; margin: 0 0 .35rem; }
|
|
|
|
|
+p.desc { color: #57606a; margin: 0 0 1.5rem; }
|
|
|
|
|
+label { display: block; font-weight: 600; font-size: .9rem; margin: 1rem 0 .35rem; }
|
|
|
|
|
+.req { color: #b3261e; font-weight: 400; }
|
|
|
|
|
+input[type=text], input[type=number], input[type=date], input[type=password], textarea, select {
|
|
|
|
|
+ width: 100%; box-sizing: border-box; padding: .55rem .65rem; font-size: 1rem;
|
|
|
|
|
+ border: 1px solid #d0d7de; border-radius: 6px; background: #fff; }
|
|
|
|
|
+textarea { min-height: 6rem; resize: vertical; }
|
|
|
|
|
+input[type=file] { width: 100%; }
|
|
|
|
|
+button { margin-top: 1.5rem; width: 100%; padding: .7rem; font-size: 1rem; font-weight: 600;
|
|
|
|
|
+ color: #fff; background: #1f6feb; border: 0; border-radius: 6px; cursor: pointer; }
|
|
|
|
|
+button:hover { background: #1a5fd0; }
|
|
|
|
|
+.err { background: #fff1f0; border: 1px solid #ffccc7; color: #a8071a;
|
|
|
|
|
+ padding: .65rem .8rem; border-radius: 6px; margin-bottom: 1rem; font-size: .9rem; }
|
|
|
|
|
+)";
|
|
|
|
|
+
|
|
|
|
|
+std::string page(const std::string& title, const std::string& body) {
|
|
|
|
|
+ std::ostringstream out;
|
|
|
|
|
+ out << "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">"
|
|
|
|
|
+ << "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
|
|
|
|
|
+ << "<title>" << escapeHtml(title) << "</title><style>" << kStyle << "</style></head>"
|
|
|
|
|
+ << "<body><div class=\"card\">" << body << "</div></body></html>";
|
|
|
|
|
+ return out.str();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+std::string errorBlock(const std::string& message) {
|
|
|
|
|
+ if (message.empty()) return "";
|
|
|
|
|
+ return "<div class=\"err\">" + escapeHtml(message) + "</div>";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace
|
|
|
|
|
+
|
|
|
|
|
+std::string escapeHtml(const std::string& raw) {
|
|
|
|
|
+ std::string out;
|
|
|
|
|
+ out.reserve(raw.size());
|
|
|
|
|
+ for (char c : raw) {
|
|
|
|
|
+ switch (c) {
|
|
|
|
|
+ case '&': out += "&"; break;
|
|
|
|
|
+ case '<': out += "<"; break;
|
|
|
|
|
+ case '>': out += ">"; break;
|
|
|
|
|
+ case '"': out += """; break;
|
|
|
|
|
+ case '\'': out += "'"; break;
|
|
|
|
|
+ default: out += c; break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+std::string renderForm(const nlohmann::json& config, const std::string& action_url,
|
|
|
|
|
+ const std::string& error_message) {
|
|
|
|
|
+ const auto fields = config.value("fields", nlohmann::json::array());
|
|
|
|
|
+
|
|
|
|
|
+ bool has_file = false;
|
|
|
|
|
+ for (const auto& f : fields) {
|
|
|
|
|
+ if (f.value("type", "text") == "file") { has_file = true; break; }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ std::ostringstream body;
|
|
|
|
|
+ body << "<h1>" << escapeHtml(config.value("title", "Untitled form")) << "</h1>";
|
|
|
|
|
+ const std::string desc = config.value("description", "");
|
|
|
|
|
+ if (!desc.empty()) body << "<p class=\"desc\">" << escapeHtml(desc) << "</p>";
|
|
|
|
|
+ body << errorBlock(error_message);
|
|
|
|
|
+
|
|
|
|
|
+ body << "<form method=\"POST\" action=\"" << escapeHtml(action_url) << "\"";
|
|
|
|
|
+ // Without this a browser sends urlencoded and the file arrives as a bare
|
|
|
|
|
+ // filename, which looks like a working form that silently loses the upload.
|
|
|
|
|
+ if (has_file) body << " enctype=\"multipart/form-data\"";
|
|
|
|
|
+ body << ">";
|
|
|
|
|
+
|
|
|
|
|
+ for (const auto& f : fields) {
|
|
|
|
|
+ const std::string name = f.value("name", "");
|
|
|
|
|
+ if (name.empty()) continue;
|
|
|
|
|
+ const std::string type = f.value("type", "text");
|
|
|
|
|
+ const std::string label = f.value("label", name);
|
|
|
|
|
+ const bool required = f.value("required", false);
|
|
|
|
|
+ const std::string placeholder = escapeHtml(f.value("placeholder", ""));
|
|
|
|
|
+ const std::string req_attr = required ? " required" : "";
|
|
|
|
|
+
|
|
|
|
|
+ body << "<label for=\"" << escapeHtml(name) << "\">" << escapeHtml(label);
|
|
|
|
|
+ if (required) body << " <span class=\"req\">*</span>";
|
|
|
|
|
+ body << "</label>";
|
|
|
|
|
+
|
|
|
|
|
+ const std::string common = "id=\"" + escapeHtml(name) + "\" name=\"" + escapeHtml(name) + "\"";
|
|
|
|
|
+
|
|
|
|
|
+ if (type == "textarea") {
|
|
|
|
|
+ body << "<textarea " << common << " placeholder=\"" << placeholder << "\""
|
|
|
|
|
+ << req_attr << "></textarea>";
|
|
|
|
|
+ } else if (type == "select") {
|
|
|
|
|
+ body << "<select " << common << req_attr << ">";
|
|
|
|
|
+ if (!required) body << "<option value=\"\"></option>";
|
|
|
|
|
+ for (const auto& opt : f.value("options", nlohmann::json::array())) {
|
|
|
|
|
+ const std::string v = escapeHtml(opt.is_string() ? opt.get<std::string>() : opt.dump());
|
|
|
|
|
+ body << "<option value=\"" << v << "\">" << v << "</option>";
|
|
|
|
|
+ }
|
|
|
|
|
+ body << "</select>";
|
|
|
|
|
+ } else if (type == "checkbox") {
|
|
|
|
|
+ body << "<input type=\"checkbox\" " << common << " value=\"true\"" << req_attr << ">";
|
|
|
|
|
+ } else if (type == "file") {
|
|
|
|
|
+ body << "<input type=\"file\" " << common;
|
|
|
|
|
+ const std::string accept = f.value("accept", "");
|
|
|
|
|
+ if (!accept.empty()) body << " accept=\"" << escapeHtml(accept) << "\"";
|
|
|
|
|
+ body << req_attr << ">";
|
|
|
|
|
+ } else if (type == "number") {
|
|
|
|
|
+ body << "<input type=\"number\" " << common << " placeholder=\"" << placeholder
|
|
|
|
|
+ << "\"" << req_attr << ">";
|
|
|
|
|
+ } else if (type == "date") {
|
|
|
|
|
+ body << "<input type=\"date\" " << common << req_attr << ">";
|
|
|
|
|
+ } else {
|
|
|
|
|
+ body << "<input type=\"text\" " << common << " placeholder=\"" << placeholder
|
|
|
|
|
+ << "\"" << req_attr << ">";
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ body << "<button type=\"submit\">Submit</button></form>";
|
|
|
|
|
+ return page(config.value("title", "Form"), body.str());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+std::string renderPasswordPrompt(const std::string& action_url, const std::string& error_message) {
|
|
|
|
|
+ std::ostringstream body;
|
|
|
|
|
+ body << "<h1>This form is protected</h1>"
|
|
|
|
|
+ << "<p class=\"desc\">Enter the password you were given.</p>"
|
|
|
|
|
+ << errorBlock(error_message)
|
|
|
|
|
+ << "<form method=\"POST\" action=\"" << escapeHtml(action_url) << "\">"
|
|
|
|
|
+ << "<label for=\"__form_password\">Password</label>"
|
|
|
|
|
+ << "<input type=\"password\" id=\"__form_password\" name=\"__form_password\" required>"
|
|
|
|
|
+ << "<button type=\"submit\">Continue</button></form>";
|
|
|
|
|
+ return page("Protected form", body.str());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+std::string renderMessage(const std::string& title, const std::string& message) {
|
|
|
|
|
+ std::ostringstream body;
|
|
|
|
|
+ body << "<h1>" << escapeHtml(title) << "</h1>"
|
|
|
|
|
+ << "<p class=\"desc\">" << escapeHtml(message) << "</p>";
|
|
|
|
|
+ return page(title, body.str());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace smartbotic::webserver::api::form_renderer
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Add `src/webserver/api/form_renderer.cpp` to the webserver target's source list in `CMakeLists.txt`, beside the other `src/webserver/api/*.cpp` entries.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Branch on the form node in the controller**
|
|
|
|
|
+
|
|
|
|
|
+In `webhook_controller.hpp`, declare beside the existing helpers:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ std::optional<nlohmann::json> findFormNode(const nlohmann::json& workflow);
|
|
|
|
|
+ void handleFormGet(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const nlohmann::json& node, const std::string& workflow_id,
|
|
|
|
|
+ const std::string& path);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+In `webhook_controller.cpp`, inside `handleWebhook`, immediately after the `active` check and **before** `findTriggerNode`:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ // A form is one node answering two verbs - GET renders it, POST submits it -
|
|
|
|
|
+ // which the method-to-node-type mapping below cannot express, so it is
|
|
|
|
|
+ // matched first.
|
|
|
|
|
+ auto form_node = findFormNode(workflow);
|
|
|
|
|
+ if (form_node.has_value()) {
|
|
|
|
|
+ const auto form_config = form_node->value("config", nlohmann::json::object());
|
|
|
|
|
+ const std::string config_path = form_config.value("path", "");
|
|
|
|
|
+ if (!config_path.empty() && path != config_path) {
|
|
|
|
|
+ sendError(res, "Path not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (req.method == "GET") {
|
|
|
|
|
+ handleFormGet(req, res, form_node.value(), workflow_id, path);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+And the two new methods:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+std::optional<nlohmann::json> WebhookController::findFormNode(const nlohmann::json& workflow) {
|
|
|
|
|
+ for (const auto& node : workflow.value("nodes", nlohmann::json::array())) {
|
|
|
|
|
+ if (node.value("type", "") == "form-trigger" && !node.value("disabled", false)) {
|
|
|
|
|
+ return node;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return std::nullopt;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void WebhookController::handleFormGet(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const nlohmann::json& node, const std::string& workflow_id,
|
|
|
|
|
+ const std::string& path) {
|
|
|
|
|
+ (void)req;
|
|
|
|
|
+ const auto config = node.value("config", nlohmann::json::object());
|
|
|
|
|
+ const std::string action = "/webhook/" + workflow_id + path;
|
|
|
|
|
+ res.status = 200;
|
|
|
|
|
+ res.set_content(form_renderer::renderForm(config, action, ""), "text/html; charset=utf-8");
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Add `#include "form_renderer.hpp"` at the top.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Rebuild, restart, verify**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cmake --build build -j$(nproc) && systemctl --user restart smartbotic-webserver && sleep 5
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-render.json
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-render-escaping.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: both PASS.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Run the whole suite and commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+./scripts/run-node-tests.sh
|
|
|
|
|
+git add src/webserver/api/form_renderer.hpp src/webserver/api/form_renderer.cpp \
|
|
|
|
|
+ src/webserver/api/webhook_controller.cpp src/webserver/api/webhook_controller.hpp \
|
|
|
|
|
+ CMakeLists.txt scripts/verify-node.py tests/nodes/form-render.json tests/nodes/form-render-escaping.json
|
|
|
|
|
+git commit -m "feat: serve a form-trigger workflow's form as HTML"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 4: Accept the submission
|
|
|
|
|
+
|
|
|
|
|
+Parse a POST into trigger data, converting uploads into the binary object the rest of the platform already understands.
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `src/webserver/api/webhook_controller.cpp`, `src/webserver/api/webhook_controller.hpp`
|
|
|
|
|
+- Test: `tests/nodes/form-submit.json`, `tests/nodes/form-submit-required.json`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `findFormNode` and `form_renderer` from Task 3.
|
|
|
|
|
+- Produces: `bool buildFormTriggerData(const httplib::Request&, const nlohmann::json& config, nlohmann::json& out, std::string& error)` - returns false with a human-readable `error` when validation fails. Task 5 calls it before dispatching.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing tests**
|
|
|
|
|
+
|
|
|
|
|
+The harness cannot send multipart yet. Add a `multipart` branch to `run_http_case` in `scripts/verify-node.py`:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ multipart = spec.get("multipart")
|
|
|
|
|
+ if multipart:
|
|
|
|
|
+ boundary = "----smartboticverify"
|
|
|
|
|
+ parts = []
|
|
|
|
|
+ for name, value in multipart.get("fields", {}).items():
|
|
|
|
|
+ parts.append(
|
|
|
|
|
+ '--%s\r\nContent-Disposition: form-data; name="%s"\r\n\r\n%s\r\n' % (boundary, name, value))
|
|
|
|
|
+ for name, f in multipart.get("files", {}).items():
|
|
|
|
|
+ # A tiny real PNG, so the payload is genuinely binary rather than text
|
|
|
|
|
+ # that happens to be labelled as an image.
|
|
|
|
|
+ blob = base64.b64decode(
|
|
|
|
|
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
|
|
|
|
|
+ head = ('--%s\r\nContent-Disposition: form-data; name="%s"; filename="%s"\r\n'
|
|
|
|
|
+ 'Content-Type: %s\r\n\r\n' % (boundary, name, f.get("filename", "upload.png"),
|
|
|
|
|
+ f.get("contentType", "image/png")))
|
|
|
|
|
+ parts.append(head.encode() + blob + b"\r\n")
|
|
|
|
|
+ payload = b"".join(p.encode() if isinstance(p, str) else p for p in parts)
|
|
|
|
|
+ payload += ("--%s--\r\n" % boundary).encode()
|
|
|
|
|
+ req = urllib.request.Request(url, data=payload, method="POST")
|
|
|
|
|
+ req.add_header("Content-Type", "multipart/form-data; boundary=%s" % boundary)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Import `base64` at the top of the file. This branch replaces the JSON request construction when `multipart` is present.
|
|
|
|
|
+
|
|
|
|
|
+`tests/nodes/form-submit.json` - an upload must reach the workflow as a binary object with the right mime type and a non-zero size:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "name": "verify-form-submit",
|
|
|
|
|
+ "nodes": [
|
|
|
|
|
+ {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
|
|
|
|
|
+ "config": {"title": "Upload", "responseMode": "wait",
|
|
|
|
|
+ "fields": [
|
|
|
|
|
+ {"name": "caption", "label": "Caption", "type": "text"},
|
|
|
|
|
+ {"name": "image", "label": "Image", "type": "file", "required": true}
|
|
|
|
|
+ ]}},
|
|
|
|
|
+ {"id": "n2", "name": "Respond", "type": "respond-to-webhook", "position": {"x": 0, "y": 100},
|
|
|
|
|
+ "config": {"status": 200, "bodySource": "json",
|
|
|
|
|
+ "body": "{\"caption\": \"{{$node['Form'].form.caption}}\", \"mime\": \"{{$node['Form'].form.image.mimeType}}\", \"kind\": \"{{$node['Form'].form.image.type}}\", \"size\": {{$node['Form'].form.image.size}}}",
|
|
|
|
|
+ "contentType": "application/json"}}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "connections": [
|
|
|
|
|
+ {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "http": {
|
|
|
|
|
+ "method": "POST",
|
|
|
|
|
+ "path": "/webhook/{workflowId}",
|
|
|
|
|
+ "multipart": {"fields": {"caption": "a small square"},
|
|
|
|
|
+ "files": {"image": {"filename": "square.png", "contentType": "image/png"}}},
|
|
|
|
|
+ "expectStatus": 200,
|
|
|
|
|
+ "expectBodyContains": ["\"caption\":\"a small square\"", "\"mime\":\"image/png\"", "\"kind\":\"binary\""],
|
|
|
|
|
+ "expectBodyExcludes": ["\"size\":0"]
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`tests/nodes/form-submit-required.json` - a missing required answer is refused and the form comes back with the reason, rather than starting a run with a hole in it:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "name": "verify-form-submit-required",
|
|
|
|
|
+ "nodes": [
|
|
|
|
|
+ {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
|
|
|
|
|
+ "config": {"title": "Upload",
|
|
|
|
|
+ "fields": [{"name": "image", "label": "Image", "type": "file", "required": true}]}}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "connections": [],
|
|
|
|
|
+ "http": {
|
|
|
|
|
+ "method": "POST",
|
|
|
|
|
+ "path": "/webhook/{workflowId}",
|
|
|
|
|
+ "multipart": {"fields": {}, "files": {}},
|
|
|
|
|
+ "expectStatus": 400,
|
|
|
|
|
+ "expectBodyContains": ["Image", "required"]
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run them and watch them fail**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-submit.json
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-submit-required.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: both FAIL - the multipart body is not parsed, so `form.image` is absent.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Build the trigger data**
|
|
|
|
|
+
|
|
|
|
|
+Declare in `webhook_controller.hpp`:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ bool buildFormTriggerData(const httplib::Request& req, const nlohmann::json& config,
|
|
|
|
|
+ nlohmann::json& out, std::string& error);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+In `webhook_controller.cpp` - note `StringUtils::base64Encode` and the SHA-256 helper are the same ones the node runtime uses, so the object matches what `http-request` produces byte for byte:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+bool WebhookController::buildFormTriggerData(const httplib::Request& req,
|
|
|
|
|
+ const nlohmann::json& config,
|
|
|
|
|
+ nlohmann::json& out, std::string& error) {
|
|
|
|
|
+ nlohmann::json form = nlohmann::json::object();
|
|
|
|
|
+ const auto fields = config.value("fields", nlohmann::json::array());
|
|
|
|
|
+
|
|
|
|
|
+ for (const auto& f : fields) {
|
|
|
|
|
+ const std::string name = f.value("name", "");
|
|
|
|
|
+ if (name.empty()) continue;
|
|
|
|
|
+ const std::string type = f.value("type", "text");
|
|
|
|
|
+ const std::string label = f.value("label", name);
|
|
|
|
|
+ const bool required = f.value("required", false);
|
|
|
|
|
+
|
|
|
|
|
+ if (type == "file") {
|
|
|
|
|
+ if (!req.has_file(name)) {
|
|
|
|
|
+ if (required) {
|
|
|
|
|
+ error = label + " is required.";
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const auto file = req.get_file_value(name);
|
|
|
|
|
+
|
|
|
|
|
+ // A per-field ceiling under the server-wide one from Task 2. The
|
|
|
|
|
+ // server limit has already refused anything larger than itself, so
|
|
|
|
|
+ // this only tightens, never loosens.
|
|
|
|
|
+ const double max_mb = f.value("maxSizeMb", 0.0);
|
|
|
|
|
+ if (max_mb > 0 && static_cast<double>(file.content.size()) > max_mb * 1024 * 1024) {
|
|
|
|
|
+ error = label + " is larger than the " + std::to_string(static_cast<int>(max_mb)) +
|
|
|
|
|
+ " MB limit for this field.";
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const std::string b64 = common::StringUtils::base64Encode(file.content);
|
|
|
|
|
+ form[name] = {
|
|
|
|
|
+ {"type", "binary"},
|
|
|
|
|
+ {"data", b64},
|
|
|
|
|
+ {"mimeType", file.content_type.empty() ? "application/octet-stream" : file.content_type},
|
|
|
|
|
+ {"filename", file.filename},
|
|
|
|
|
+ {"size", file.content.size()},
|
|
|
|
|
+ {"checksum", common::StringUtils::sha256Hex(b64)}
|
|
|
|
|
+ };
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ std::string value;
|
|
|
|
|
+ if (req.has_param(name.c_str())) value = req.get_param_value(name.c_str());
|
|
|
|
|
+
|
|
|
|
|
+ if (type == "checkbox") {
|
|
|
|
|
+ form[name] = !value.empty();
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (value.empty()) {
|
|
|
|
|
+ if (required) {
|
|
|
|
|
+ error = label + " is required.";
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ form[name] = (type == "number") ? nlohmann::json(nullptr) : nlohmann::json("");
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (type == "number") {
|
|
|
|
|
+ try {
|
|
|
|
|
+ form[name] = std::stod(value);
|
|
|
|
|
+ } catch (const std::exception&) {
|
|
|
|
|
+ error = label + " must be a number.";
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ form[name] = value;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ out = nlohmann::json::object();
|
|
|
|
|
+ out["form"] = form;
|
|
|
|
|
+ out["submittedAt"] = TimeUtils::nowMs();
|
|
|
|
|
+ out["clientIp"] = req.remote_addr;
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`StringUtils::base64Encode` exists (`lib/common/string_utils.hpp:52`), but there is **no shared `sha256Hex`** - the only implementation is a file-static `sha256Hash` in `src/runner/engine/script_engine.cpp:84`, which the webserver cannot reach. Add one to `lib/common/string_utils.{hpp,cpp}`:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ /// Hex SHA-256 of the given bytes.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// A binary object's `checksum` is the hash of its base64 text, not of the
|
|
|
|
|
+ /// raw bytes - `http-request.js:457` does `sha256(base64Data)`. An upload
|
|
|
|
|
+ /// and a download of the same image must produce the same checksum or
|
|
|
|
|
+ /// deduplication downstream silently stops working, so hash the same input.
|
|
|
|
|
+ static std::string sha256Hex(std::string_view data);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+implemented with `EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr)` as `script_engine.cpp:84` does. Do not inline OpenSSL into the controller.
|
|
|
|
|
+
|
|
|
|
|
+Verify the two agree before moving on: upload an image through the form and download the same file through an `http-request` node, then compare the `checksum` on each. They must be identical.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Call it from the POST path**
|
|
|
|
|
+
|
|
|
|
|
+Extend the form branch added in Task 3, after the GET handling:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ if (req.method == "POST") {
|
|
|
|
|
+ nlohmann::json form_data;
|
|
|
|
|
+ std::string error;
|
|
|
|
|
+ if (!buildFormTriggerData(req, form_config, form_data, error)) {
|
|
|
|
|
+ res.status = 400;
|
|
|
|
|
+ const std::string action = "/webhook/" + workflow_id + path;
|
|
|
|
|
+ res.set_content(form_renderer::renderForm(form_config, action, error),
|
|
|
|
|
+ "text/html; charset=utf-8");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ form_trigger_data = form_data; // consumed below, in place of the JSON body
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Declare `nlohmann::json form_trigger_data;` above the branch, and where `trigger_data` is assembled (around line 128) merge it in rather than parsing the body:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ if (!form_trigger_data.is_null()) {
|
|
|
|
|
+ trigger_data["form"] = form_trigger_data["form"];
|
|
|
|
|
+ trigger_data["submittedAt"] = form_trigger_data["submittedAt"];
|
|
|
|
|
+ } else if (!req.body.empty()) {
|
|
|
|
|
+ ...existing JSON body parsing...
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Rebuild, restart, verify**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cmake --build build -j$(nproc) && systemctl --user restart smartbotic-webserver && sleep 5
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-submit.json
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-submit-required.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: both PASS.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Run the whole suite and commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+./scripts/run-node-tests.sh
|
|
|
|
|
+git add src/webserver/api/webhook_controller.cpp src/webserver/api/webhook_controller.hpp \
|
|
|
|
|
+ scripts/verify-node.py tests/nodes/form-submit.json tests/nodes/form-submit-required.json
|
|
|
|
|
+git commit -m "feat: accept a form submission, uploads included"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 5: Response modes
|
|
|
|
|
+
|
|
|
|
|
+`immediate` returns the thank-you page at once; `wait` keeps today's blocking behaviour.
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `src/webserver/api/webhook_controller.cpp`
|
|
|
|
|
+- Test: `tests/nodes/form-immediate.json`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `buildFormTriggerData` from Task 4, `renderMessage` from Task 3.
|
|
|
|
|
+- Produces: nothing new.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+A form whose workflow sleeps well past the webhook timeout must still answer immediately:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "name": "verify-form-immediate",
|
|
|
|
|
+ "nodes": [
|
|
|
|
|
+ {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
|
|
|
|
|
+ "config": {"title": "Slow job", "responseMode": "immediate",
|
|
|
|
|
+ "responseMessage": "Started, check back later.",
|
|
|
|
|
+ "fields": [{"name": "note", "label": "Note", "type": "text"}]}},
|
|
|
|
|
+ {"id": "n2", "name": "Slow", "type": "code", "position": {"x": 0, "y": 100},
|
|
|
|
|
+ "config": {"code": "const end = Date.now() + 40000; while (Date.now() < end) {} return { done: true };",
|
|
|
|
|
+ "timeout": 60}}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "connections": [
|
|
|
|
|
+ {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "http": {
|
|
|
|
|
+ "method": "POST",
|
|
|
|
|
+ "path": "/webhook/{workflowId}",
|
|
|
|
|
+ "multipart": {"fields": {"note": "hello"}, "files": {}},
|
|
|
|
|
+ "expectStatus": 200,
|
|
|
|
|
+ "expectBodyContains": ["Started, check back later."]
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+The harness times out at 45 seconds, so a response that waited for this 40-second run would either fail the timeout or arrive far too late to contain the message.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run it and watch it fail**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-immediate.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: FAIL - the request blocks for the whole run and does not return the thank-you page.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Dispatch without waiting**
|
|
|
|
|
+
|
|
|
|
|
+Where the gRPC request is built (around `webhook_controller.cpp:166`):
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ // A form set to answer immediately must not hold the browser for the run.
|
|
|
|
|
+ // The slot claimed above is still released correctly: the runner reports
|
|
|
|
|
+ // execution.completed/failed/cancelled/waiting to
|
|
|
|
|
+ // /api/v1/internal/execution-event, and execution_controller.cpp:632
|
|
|
|
|
+ // releases it there - the same path fire-and-forget scheduled dispatch
|
|
|
|
|
+ // relies on today.
|
|
|
|
|
+ const bool wait_for_run = form_trigger_data.is_null() || form_response_mode != "immediate";
|
|
|
|
|
+ grpc_req.set_wait_for_completion(wait_for_run);
|
|
|
|
|
+ grpc_req.set_timeout_ms(wait_for_run ? 30000 : 0);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Set `form_response_mode` from the config in the form branch:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ form_response_mode = form_config.value("responseMode", "immediate");
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+And after the gRPC call returns, before the existing result handling:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ if (!form_trigger_data.is_null() && form_response_mode == "immediate") {
|
|
|
|
|
+ res.status = 200;
|
|
|
|
|
+ res.set_content(form_renderer::renderMessage(
|
|
|
|
|
+ form_config_title,
|
|
|
|
|
+ form_response_message),
|
|
|
|
|
+ "text/html; charset=utf-8");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Capture `form_config_title` and `form_response_message` in the form branch, defaulting the message to `"Thanks, your answer was received."`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Rebuild, restart, verify both modes**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cmake --build build -j$(nproc) && systemctl --user restart smartbotic-webserver && sleep 5
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-immediate.json
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-submit.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: both PASS. The second is the `wait` mode case from Task 4 - it must still block and return the workflow's own response, or this change broke it.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Confirm the scheduler slot was released**
|
|
|
|
|
+
|
|
|
|
|
+An immediate-mode run that leaks its slot would stall the workflow's schedule for up to an hour, and no assertion above would notice:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+journalctl --user -u smartbotic-webserver --since "3 minutes ago" | grep -i "slot\|deadline" | tail -20
|
|
|
|
|
+```
|
|
|
|
|
+Expected: no "exceeded its deadline" for the test workflow.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Run the whole suite and commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+./scripts/run-node-tests.sh
|
|
|
|
|
+git add src/webserver/api/webhook_controller.cpp tests/nodes/form-immediate.json
|
|
|
|
|
+git commit -m "feat: a form can answer at once instead of holding the browser"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 6: The form password
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `src/webserver/api/webhook_controller.cpp`, `src/webserver/api/webhook_controller.hpp`
|
|
|
|
|
+- Test: `tests/nodes/form-password.json`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `renderPasswordPrompt` from Task 3.
|
|
|
|
|
+- Produces: `std::string formCookieToken(const std::string& workflow_id, int64_t expires_at)` and `bool formCookieValid(const httplib::Request&, const std::string& workflow_id)`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "name": "verify-form-password",
|
|
|
|
|
+ "nodes": [
|
|
|
|
|
+ {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
|
|
|
|
|
+ "config": {"title": "Private form", "password": "hunter2",
|
|
|
|
|
+ "fields": [{"name": "note", "label": "Note", "type": "text"}]}}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "connections": [],
|
|
|
|
|
+ "http": {
|
|
|
|
|
+ "method": "GET",
|
|
|
|
|
+ "path": "/webhook/{workflowId}",
|
|
|
|
|
+ "expectStatus": 200,
|
|
|
|
|
+ "expectBodyContains": ["This form is protected", "__form_password"],
|
|
|
|
|
+ "expectBodyExcludes": ["hunter2", "name=\"note\""]
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+The exclusions matter as much as the inclusions: the prompt must not leak the password into the page, and must not render the real fields behind it.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run it and watch it fail**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-password.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: FAIL - the form renders regardless of the password.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Implement the cookie and the gate**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+std::string WebhookController::formCookieToken(const std::string& workflow_id, int64_t expires_at) {
|
|
|
|
|
+ // A signed token rather than the password: the password never reaches the
|
|
|
|
|
+ // browser, and a stolen cookie opens one form until it expires.
|
|
|
|
|
+ const std::string payload = workflow_id + ":" + std::to_string(expires_at);
|
|
|
|
|
+ return payload + ":" + jwt_utils_.signDetached(payload);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+bool WebhookController::formCookieValid(const httplib::Request& req, const std::string& workflow_id) {
|
|
|
|
|
+ const std::string cookie_header = req.get_header_value("Cookie");
|
|
|
|
|
+ const std::string key = "sb_form_" + workflow_id + "=";
|
|
|
|
|
+ const auto at = cookie_header.find(key);
|
|
|
|
|
+ if (at == std::string::npos) return false;
|
|
|
|
|
+
|
|
|
|
|
+ std::string value = cookie_header.substr(at + key.size());
|
|
|
|
|
+ const auto end = value.find(';');
|
|
|
|
|
+ if (end != std::string::npos) value = value.substr(0, end);
|
|
|
|
|
+
|
|
|
|
|
+ const auto first = value.find(':');
|
|
|
|
|
+ const auto second = value.rfind(':');
|
|
|
|
|
+ if (first == std::string::npos || second == first) return false;
|
|
|
|
|
+
|
|
|
|
|
+ const std::string payload = value.substr(0, second);
|
|
|
|
|
+ const std::string signature = value.substr(second + 1);
|
|
|
|
|
+ if (!jwt_utils_.verifyDetached(payload, signature)) return false;
|
|
|
|
|
+
|
|
|
|
|
+ const std::string id = payload.substr(0, first);
|
|
|
|
|
+ if (id != workflow_id) return false;
|
|
|
|
|
+
|
|
|
|
|
+ int64_t expires_at = 0;
|
|
|
|
|
+ try {
|
|
|
|
|
+ expires_at = std::stoll(payload.substr(first + 1));
|
|
|
|
|
+ } catch (const std::exception&) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return TimeUtils::nowMs() < expires_at;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`JwtUtils::sign` and `verifySignature` are private (`jwt_utils.hpp:64-65`). Expose them as public `signDetached` / `verifyDetached` wrappers rather than copying the HMAC code - one signing implementation, one secret. Make `verifyDetached` compare with `CRYPTO_memcmp` rather than `==`, so the comparison is constant-time as the spec requires; `verifySignature` uses `==` today and should be switched too.
|
|
|
|
|
+
|
|
|
|
|
+`WebhookController` needs a `JwtUtils&` member. Pass it in from `webserver_service.cpp:391` where the controller is constructed.
|
|
|
|
|
+
|
|
|
|
|
+In the form branch, before rendering or submitting:
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+ const std::string form_password = form_config.value("password", "");
|
|
|
|
|
+ if (!form_password.empty() && !formCookieValid(req, workflow_id)) {
|
|
|
|
|
+ const std::string action = "/webhook/" + workflow_id + path;
|
|
|
|
|
+ std::string attempt;
|
|
|
|
|
+ if (req.method == "POST" && req.has_param("__form_password")) {
|
|
|
|
|
+ attempt = req.get_param_value("__form_password");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!attempt.empty() && attempt.size() == form_password.size() &&
|
|
|
|
|
+ CRYPTO_memcmp(attempt.data(), form_password.data(), attempt.size()) == 0) {
|
|
|
|
|
+ const int64_t expires_at = TimeUtils::nowMs() + 3600 * 1000;
|
|
|
|
|
+ res.set_header("Set-Cookie",
|
|
|
|
|
+ "sb_form_" + workflow_id + "=" + formCookieToken(workflow_id, expires_at) +
|
|
|
|
|
+ "; Path=/webhook/" + workflow_id + "; HttpOnly; SameSite=Lax; Max-Age=3600");
|
|
|
|
|
+ res.status = 200;
|
|
|
|
|
+ res.set_content(form_renderer::renderForm(form_config, action, ""),
|
|
|
|
|
+ "text/html; charset=utf-8");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ res.status = 200;
|
|
|
|
|
+ // Deliberately generic: a message naming the form would confirm that
|
|
|
|
|
+ // one exists at this URL to somebody guessing.
|
|
|
|
|
+ res.set_content(form_renderer::renderPasswordPrompt(
|
|
|
|
|
+ action, attempt.empty() ? "" : "That did not work."),
|
|
|
|
|
+ "text/html; charset=utf-8");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Rebuild, restart, verify both directions**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cmake --build build -j$(nproc) && systemctl --user restart smartbotic-webserver && sleep 5
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-password.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: PASS.
|
|
|
|
|
+
|
|
|
|
|
+Now check the gate opens as well as closes - a password that never accepts would pass the test above. Create the same workflow by hand, POST the right password, and confirm the fields appear:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+TOKEN=$(curl -s http://localhost:8090/api/v1/auth/login -H "Content-Type: application/json" \
|
|
|
|
|
+ -d '{"username": "admin", "password": "admin"}' | jq -r '.accessToken')
|
|
|
|
|
+# create a form workflow with password hunter2, publish and activate it, note its id as WF
|
|
|
|
|
+curl -s -X POST "http://localhost:8090/api/v1/../webhook/$WF" \
|
|
|
|
|
+ --data-urlencode "__form_password=hunter2" -i | head -20
|
|
|
|
|
+```
|
|
|
|
|
+Expected: a `Set-Cookie: sb_form_...` header and a body containing the real field.
|
|
|
|
|
+
|
|
|
|
|
+Also confirm a form with no password still renders directly:
|
|
|
|
|
+```bash
|
|
|
|
|
+python3 scripts/verify-node.py tests/nodes/form-render.json
|
|
|
|
|
+```
|
|
|
|
|
+Expected: PASS.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Run the whole suite and commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+./scripts/run-node-tests.sh
|
|
|
|
|
+git add src/webserver/api/webhook_controller.cpp src/webserver/api/webhook_controller.hpp \
|
|
|
|
|
+ src/webserver/auth/jwt_utils.cpp src/webserver/auth/jwt_utils.hpp \
|
|
|
|
|
+ src/webserver/webserver_service.cpp tests/nodes/form-password.json
|
|
|
|
|
+git commit -m "feat: an optional password on a shared form"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 7: The form URL in the editor
|
|
|
|
|
+
|
|
|
|
|
+The field editor needs no new component: `ArrayFieldEditor.tsx` builds rows from `prop.items.properties`, which Task 1 declared. **Verify that before writing any code** - if it renders, this task is only the URL.
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `webui/src/components/workflow/NodeConfigModal.tsx`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Check the field editor renders**
|
|
|
|
|
+
|
|
|
|
|
+Open a workflow, add a Form node, open its settings. The Fields setting should already show a row per entry with Name, Label, Type, Required, Placeholder, Options, Accept and Max Size controls.
|
|
|
|
|
+
|
|
|
|
|
+If it does not, fix `ArrayFieldEditor` to handle the item schema rather than writing a form-specific editor - every other array-valued setting benefits from the same fix.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Show the URL**
|
|
|
|
|
+
|
|
|
|
|
+In `NodeConfigModal.tsx`, when the node type is `form-trigger`, render the form's address above the settings with a copy button:
|
|
|
|
|
+
|
|
|
|
|
+```tsx
|
|
|
|
|
+{node.type === 'form-trigger' && (
|
|
|
|
|
+ <div className="mb-4 rounded border border-gray-200 bg-gray-50 p-3">
|
|
|
|
|
+ <div className="text-xs font-medium text-gray-600">Form address</div>
|
|
|
|
|
+ <div className="mt-1 flex items-center gap-2">
|
|
|
|
|
+ <code className="flex-1 truncate text-sm">{formUrl}</code>
|
|
|
|
|
+ <button type="button" onClick={() => navigator.clipboard.writeText(formUrl)}
|
|
|
|
|
+ className="rounded border px-2 py-1 text-xs">Copy</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p className="mt-2 text-xs text-gray-500">
|
|
|
|
|
+ Anyone with this address can open the form once the workflow is published and active.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+)}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+with `const formUrl = `${window.location.origin}/webhook/${workflowId}${config.path || ''}``.
|
|
|
|
|
+
|
|
|
|
|
+The note is not decoration: publish-and-active is exactly what `handleWebhook` requires, and a form that silently 404s because the workflow was never published is the first thing somebody will hit.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Verify in the browser**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd webui && npm run lint && npm run build
|
|
|
|
|
+```
|
|
|
|
|
+Then load a workflow with a Form node, copy the URL, open it in a new tab, and confirm the form renders.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add webui/src/components/workflow/NodeConfigModal.tsx
|
|
|
|
|
+git commit -m "feat: show a form's address in the node editor"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 8: Extract the anime chain into a sub-workflow
|
|
|
|
|
+
|
|
|
|
|
+The ten nodes from `Vision Analyse` to `Publish To Blog` were checked for references outside themselves - `$node[...]`, `data.loop.*`, and any mention of the feed - and there are none. The only input is the image.
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `deploy/workflows/swf-anime-process-one-image.json`
|
|
|
|
|
+- Modify: `deploy/workflows/35photo2anime-sdcpp.json`
|
|
|
|
|
+- Live: the workflows in the database, via the API
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: nothing from earlier tasks.
|
|
|
|
|
+- Produces: sub-workflow `[SWF] anime: process one image` with a `workflow-input` node declaring one required field, `file` (binary). Task 9 calls it.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Take a backup before touching a live pipeline**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+TOKEN=$(curl -s http://localhost:8090/api/v1/auth/login -H "Content-Type: application/json" \
|
|
|
|
|
+ -d '{"username": "admin", "password": "admin"}' | jq -r '.accessToken')
|
|
|
|
|
+curl -s "http://localhost:8090/api/v1/workflows/wf_e6140002-c6b1-428c-afab-68a301591ce6" \
|
|
|
|
|
+ -H "Authorization: Bearer $TOKEN" > /tmp/35photo-before.json
|
|
|
|
|
+test -s /tmp/35photo-before.json && echo "backup ok"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Build the sub-workflow**
|
|
|
|
|
+
|
|
|
|
|
+Copy the ten nodes and the connections among them out of `/tmp/35photo-before.json` into a new workflow named `[SWF] anime: process one image`, and put a `workflow-input` node in front of `Vision Analyse`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{"id": "node_input", "name": "Workflow Input", "type": "workflow-input",
|
|
|
|
|
+ "position": {"x": 0, "y": 0},
|
|
|
|
|
+ "config": {"allowExtra": true,
|
|
|
|
|
+ "fields": [{"name": "file", "required": true,
|
|
|
|
|
+ "description": "The image, as the binary object a download or an upload produces"}]}}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+connected to `Vision Analyse` with `{"sourceNodeId": "node_input", "sourceOutput": "main", "targetNodeId": "node_ollama_vision", "targetInput": "data"}`.
|
|
|
|
|
+
|
|
|
|
|
+Create it, then publish and activate it - `call-workflow` runs the published version, so an unpublished sub-workflow fails at the call.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Prove the sub-workflow works before changing anything that runs**
|
|
|
|
|
+
|
|
|
|
|
+Call it on its own with a known image, exactly as Task 9's form will:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+# Any of the generated images from a recent run serves as a fixture.
|
|
|
|
|
+curl -s -X POST "http://localhost:8090/api/v1/workflows/$SWF_ID/execute" \
|
|
|
|
|
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
|
|
|
+ -d '{"triggerData": {"file": {"type": "binary", "data": "'"$(curl -s http://mulan:8077/output/806940f5-1a0f-46b1-8179-a3154d2c4e92/output_0.png | base64 -w0)"'", "mimeType": "image/png", "filename": "t.png"}}}'
|
|
|
|
|
+```
|
|
|
|
|
+Expected: a completed run that produces a post. **Do not go on until this passes** - the next step edits the working pipeline.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Point the RSS loop at it**
|
|
|
|
|
+
|
|
|
|
|
+In `35photo2anime - sdcpp`, delete the ten extracted nodes and connect `Fetch Image` to a new `call-workflow` node:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{"id": "node_call_process", "name": "Process Image", "type": "call-workflow",
|
|
|
|
|
+ "position": {"x": 3540, "y": 1200},
|
|
|
|
|
+ "config": {"workflowId": "<the sub-workflow id>", "inputSource": "fields",
|
|
|
|
|
+ "fields": [{"name": "file", "value": "{{$node['Fetch Image'].file}}"}]}}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Verify the RSS path still produces a post**
|
|
|
|
|
+
|
|
|
|
|
+Trigger the pipeline by its Click Trigger and watch a full run:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+curl -s -X POST "http://localhost:8090/api/v1/workflows/wf_e6140002-c6b1-428c-afab-68a301591ce6/execute" \
|
|
|
|
|
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
|
|
|
|
|
+```
|
|
|
|
|
+Expected: a completed run ending in a published post, as before the change. If it does not, restore from `/tmp/35photo-before.json` with a PUT and diagnose before retrying.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Sync the repo copies and commit**
|
|
|
|
|
+
|
|
|
|
|
+Export both workflows to `deploy/workflows/`, redacting any Nextcloud token as the existing files do.
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add deploy/workflows/
|
|
|
|
|
+git commit -m "refactor: one copy of the anime chain, called by the RSS loop"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 9: The upload form
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `deploy/workflows/anime-from-upload.json`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Build the workflow**
|
|
|
|
|
+
|
|
|
|
|
+Two nodes. The password is set deliberately: this form publishes to a live blog and there is no captcha.
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "name": "anime from upload",
|
|
|
|
|
+ "active": true,
|
|
|
|
|
+ "nodes": [
|
|
|
|
|
+ {"id": "n_form", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
|
|
|
|
|
+ "config": {"title": "Turn a photo into an anime post",
|
|
|
|
|
+ "description": "Upload a picture. It goes through the same steps as one from the feed, and is published when it is done.",
|
|
|
|
|
+ "password": "PASTE_THE_GENERATED_ONE",
|
|
|
|
|
+ "responseMode": "immediate",
|
|
|
|
|
+ "responseMessage": "Thanks - the image is being processed. The post appears on the blog in a few minutes.",
|
|
|
|
|
+ "fields": [{"name": "image", "label": "Image", "type": "file",
|
|
|
|
|
+ "required": true, "accept": "image/*", "maxSizeMb": 16}]}},
|
|
|
|
|
+ {"id": "n_process", "name": "Process Image", "type": "call-workflow", "position": {"x": 300, "y": 0},
|
|
|
|
|
+ "config": {"workflowId": "<the sub-workflow id from Task 8>", "inputSource": "fields",
|
|
|
|
|
+ "fields": [{"name": "file", "value": "{{$node['Form'].form.image}}"}]}}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "connections": [
|
|
|
|
|
+ {"sourceNodeId": "n_form", "sourceOutput": "main", "targetNodeId": "n_process", "targetInput": "data"}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "settings": {}
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Generate the password first and give it to the user, since only they can decide who receives it:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+openssl rand -base64 12
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Create the workflow, publish it, activate it.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Submit a real image through the browser**
|
|
|
|
|
+
|
|
|
|
|
+Open `http://localhost:8090/webhook/<workflow id>`, enter the password, choose a photograph, submit.
|
|
|
|
|
+
|
|
|
|
|
+Expected: the thank-you page returns at once, and within a few minutes a post appears on the blog. Compare it against a post the RSS path produced - same steps means the same shape of result.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Check the 18+ flag on the result**
|
|
|
|
|
+
|
|
|
|
|
+The upload path reaches the same rating step, so it inherits today's fix. Confirm on the new post:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+curl -s "http://localhost:8090/api/v1/executions/<the run>" -H "Authorization: Bearer $TOKEN" \
|
|
|
|
|
+ | python3 -c "import json,sys; e=json.loads(sys.stdin.read(),strict=False)['execution']; \
|
|
|
|
|
+ print([ (n['nodeId'], (n.get('output') or {}).get('json')) for n in e['nodeExecutions'] if n['nodeId']=='node_blog_rate'])"
|
|
|
|
|
+```
|
|
|
|
|
+Expected: a `{nudity, visible, sexualActivity, ...}` verdict, and `is_adult` set from it - not from a low neckline.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add deploy/workflows/anime-from-upload.json
|
|
|
|
|
+git commit -m "feat: an upload form into the anime pipeline"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 10: Document it
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `docs/nodes.md`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the section**
|
|
|
|
|
+
|
|
|
|
|
+Add a "Form trigger" section covering: the URL shape (`/webhook/{workflowId}`), that the workflow must be **published and active**, the field types, how a file arrives (`form.<name>` as a binary object, the same shape a download produces), the two response modes and why a long job needs `immediate`, and the password's real strength - stored readable in the workflow, a gate against a forwarded link rather than a secret.
|
|
|
|
|
+
|
|
|
|
|
+State plainly that there is no captcha, so a public form can be submitted repeatedly.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add docs/nodes.md
|
|
|
|
|
+git commit -m "docs: the form trigger"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Self-review notes
|
|
|
|
|
+
|
|
|
|
|
+Checked against the spec:
|
|
|
|
|
+
|
|
|
|
|
+- Part 1 (node) - Task 1. Part 2 (serving) - Tasks 3 and 4. Part 3 (upload limits) - Task 2, plus the per-field check in Task 4. Part 4 (access) - Task 6. Part 5 (response modes) - Task 5. Part 6 (anime variation) - Tasks 8 and 9. Part 7 (WebUI) - Task 7. Verification section - covered by the tests in Tasks 1-6 and the manual runs in Tasks 8 and 9.
|
|
|
|
|
+- The spec's `select`, `checkbox`, `date`, `number`, `textarea` types are all rendered in Task 3 and parsed in Task 4.
|
|
|
|
|
+- Names used consistently across tasks: `findFormNode`, `buildFormTriggerData`, `form_renderer::renderForm` / `renderPasswordPrompt` / `renderMessage` / `escapeHtml`, `formCookieToken`, `formCookieValid`, config keys `title` / `description` / `fields` / `password` / `responseMode` / `responseMessage` / `path`, and field keys `name` / `label` / `type` / `required` / `placeholder` / `options` / `accept` / `maxSizeMb`.
|
|
|
|
|
+- Two harness extensions are introduced where they are first needed and reused later: `bodyPadBytes` and the GET fix (Tasks 2 and 3), `expectBodyExcludes` (Task 3), `multipart` (Task 4).
|
|
|
|
|
+
|
|
|
|
|
+One gap worth naming rather than hiding: the spec asks for a test that field-name validation rejects a duplicate or malformed name when the workflow is saved. No task implements that validation, because nothing in the codebase validates node config on save today, and adding a validation layer is a larger change than this feature justifies. A bad name currently produces a field the workflow cannot read, which is visible immediately on the rendered form. Worth doing as its own piece of work.
|