瀏覽代碼

feat: accept a form submission, uploads included

Extends the form-trigger webhook branch to handle POST: parse
multipart/form-data and urlencoded submissions into trigger data,
convert an uploaded file into the same {type, data, mimeType,
filename, size, checksum} binary object http-request.js produces,
and refuse a request missing a required answer by redisplaying the
form with the reason instead of starting a run with a hole in it.

Add StringUtils::sha256Hex (lib/common/string_utils.{hpp,cpp}) so the
webserver can hash a file's base64 text the same way the runner does
- checksum is sha256(base64Data), not sha256(rawBytes), or an upload
and a download of the same file disagree and downstream
deduplication silently breaks.

Fix form-trigger.js: it read context.triggerData, which the QuickJS
engine never sets - only `input` carries a trigger node's payload.
The form's answers were reaching the workflow as an empty object.

Teach verify-node.py's run_http_case to send a real multipart body
so uploads can be exercised at all, and add form-submit(-required)
test cases.
fszontagh 1 月之前
父節點
當前提交
08e729f80f

+ 18 - 0
lib/common/string_utils.cpp

@@ -3,6 +3,7 @@
 #include <openssl/bio.h>
 #include <openssl/evp.h>
 #include <openssl/buffer.h>
+#include <openssl/sha.h>
 
 namespace smartbotic::common {
 
@@ -263,4 +264,21 @@ std::vector<uint8_t> StringUtils::hexDecode(std::string_view hex) {
     return result;
 }
 
+std::string StringUtils::sha256Hex(std::string_view data) {
+    unsigned char hash[SHA256_DIGEST_LENGTH];
+    EVP_MD_CTX* ctx = EVP_MD_CTX_new();
+
+    EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr);
+    EVP_DigestUpdate(ctx, data.data(), data.size());
+    EVP_DigestFinal_ex(ctx, hash, nullptr);
+    EVP_MD_CTX_free(ctx);
+
+    std::ostringstream ss;
+    for (unsigned char byte : hash) {
+        ss << std::hex << std::setfill('0') << std::setw(2) << static_cast<int>(byte);
+    }
+
+    return ss.str();
+}
+
 } // namespace smartbotic::common

+ 8 - 0
lib/common/string_utils.hpp

@@ -55,6 +55,14 @@ public:
     // Hex encoding/decoding
     static std::string hexEncode(const std::vector<uint8_t>& data);
     static std::vector<uint8_t> hexDecode(std::string_view hex);
+
+    /// 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);
 };
 
 } // namespace smartbotic::common

+ 3 - 1
nodes/triggers/form-trigger.js

@@ -93,7 +93,9 @@ 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 || {};
+  // The runner hands the trigger node's payload to `input`, not `context` -
+  // `context` only carries executionId/nodeId/workflowId.
+  const triggerData = input || {};
 
   return {
     form: triggerData.form || {},

+ 33 - 11
scripts/verify-node.py

@@ -5,6 +5,7 @@ Usage: scripts/verify-node.py tests/nodes/<case>.json
 Exit 0 if every assertion holds, 1 if one does not, 2 if the case declares a
 precondition this machine does not meet - reported as a skip, never as a pass.
 """
+import base64
 import json
 import sys
 import time
@@ -61,17 +62,38 @@ def run_http_case(case, token, workflow_id):
     spec = case["http"]
     url = BASE.replace("/api/v1", "") + spec["path"].replace("{workflowId}", workflow_id)
     method = spec.get("method", "POST")
-    data = None
-    if method in ("POST", "PUT", "PATCH"):
-        body = spec.get("body", {})
-        pad = spec.get("bodyPadBytes", 0)
-        if pad:
-            body = dict(body)
-            body["_pad"] = "x" * pad
-        data = json.dumps(body).encode()
-    req = urllib.request.Request(url, data=data, method=method)
-    if data is not None:
-        req.add_header("Content-Type", "application/json")
+    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)
+    else:
+        data = None
+        if method in ("POST", "PUT", "PATCH"):
+            body = spec.get("body", {})
+            pad = spec.get("bodyPadBytes", 0)
+            if pad:
+                body = dict(body)
+                body["_pad"] = "x" * pad
+            data = json.dumps(body).encode()
+        req = urllib.request.Request(url, data=data, method=method)
+        if data is not None:
+            req.add_header("Content-Type", "application/json")
 
     failures = []
     try:

+ 106 - 1
src/webserver/api/webhook_controller.cpp

@@ -3,6 +3,7 @@
 #include "logging/logger.hpp"
 #include "common/time_utils.hpp"
 #include "common/uuid.hpp"
+#include "common/string_utils.hpp"
 #include "proto/runner.grpc.pb.h"
 #include <grpcpp/grpcpp.h>
 #include <cctype>
@@ -62,6 +63,7 @@ void WebhookController::handleWebhook(const httplib::Request& req, httplib::Resp
     // 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.
+    nlohmann::json form_trigger_data;
     auto form_node = findFormNode(workflow);
     if (form_node.has_value()) {
         const auto form_config = form_node->value("config", nlohmann::json::object());
@@ -74,6 +76,18 @@ void WebhookController::handleWebhook(const httplib::Request& req, httplib::Resp
             handleFormGet(req, res, form_node.value(), workflow_id, path);
             return;
         }
+        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
+        }
     }
 
     // Find matching HTTP trigger node for this method
@@ -150,7 +164,10 @@ void WebhookController::handleWebhook(const httplib::Request& req, httplib::Resp
     for (const auto& [key, value] : req.headers) {
         trigger_data["headers"][key] = value;
     }
-    if (!req.body.empty()) {
+    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()) {
         try {
             trigger_data["body"] = nlohmann::json::parse(req.body);
         } catch (...) {
@@ -373,6 +390,94 @@ void WebhookController::handleFormGet(const httplib::Request& req, httplib::Resp
     res.set_content(form_renderer::renderForm(config, action, ""), "text/html; charset=utf-8");
 }
 
+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;
+        }
+
+        // cpp-httplib only fills req.params for a urlencoded body; a
+        // multipart/form-data body (required once any field is a file) puts
+        // every non-file part into req.files instead, keyed by name with an
+        // empty filename. Both sources have to be checked or a text field
+        // submitted alongside an upload reads back empty.
+        std::string value;
+        if (req.has_param(name.c_str())) {
+            value = req.get_param_value(name.c_str());
+        } else if (req.has_file(name)) {
+            value = req.get_file_value(name).content;
+        }
+
+        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;
+}
+
 bool WebhookController::validateApiKey(
     const httplib::Request& req,
     const nlohmann::json& config) {

+ 2 - 0
src/webserver/api/webhook_controller.hpp

@@ -33,6 +33,8 @@ private:
     void handleFormGet(const httplib::Request& req, httplib::Response& res,
                        const nlohmann::json& node, const std::string& workflow_id,
                        const std::string& path);
+    bool buildFormTriggerData(const httplib::Request& req, const nlohmann::json& config,
+                              nlohmann::json& out, std::string& error);
 
     void sendJson(httplib::Response& res, const nlohmann::json& data, int status = 200);
     void sendError(httplib::Response& res, const std::string& message, int status);

+ 16 - 0
tests/nodes/form-submit-required.json

@@ -0,0 +1,16 @@
+{
+  "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": {"_probe": "x"}, "files": {}},
+    "expectStatus": 400,
+    "expectBodyContains": ["Image", "required"]
+  }
+}

+ 27 - 0
tests/nodes/form-submit.json

@@ -0,0 +1,27 @@
+{
+  "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"]
+  }
+}