Преглед изворни кода

feat: serve a form-trigger workflow's form as HTML

fszontagh пре 1 месец
родитељ
комит
ccab0a1aae

+ 1 - 0
CMakeLists.txt

@@ -176,6 +176,7 @@ add_executable(smartbotic-webserver
     src/webserver/api/node_controller.cpp
     src/webserver/api/runner_controller.cpp
     src/webserver/api/webhook_controller.cpp
+    src/webserver/api/form_renderer.cpp
     src/webserver/api/database_controller.cpp
     src/webserver/api/file_controller.cpp
     src/webserver/api/proxy_controller.cpp

+ 16 - 8
scripts/verify-node.py

@@ -60,14 +60,18 @@ def run_http_case(case, token, workflow_id):
     """A webhook case asserts on the HTTP response, not on node outputs."""
     spec = case["http"]
     url = BASE.replace("/api/v1", "") + spec["path"].replace("{workflowId}", workflow_id)
-    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=spec.get("method", "POST"))
-    req.add_header("Content-Type", "application/json")
+    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")
 
     failures = []
     try:
@@ -93,6 +97,10 @@ def run_http_case(case, token, workflow_id):
         if fragment not in raw:
             failures.append("body: expected to contain %r" % fragment)
 
+    for fragment in spec.get("expectBodyExcludes", []):
+        if fragment in raw:
+            failures.append("body: expected NOT to contain %r" % fragment)
+
     return failures
 
 

+ 153 - 0
src/webserver/api/form_renderer.cpp

@@ -0,0 +1,153 @@
+#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 += "&amp;";  break;
+            case '<':  out += "&lt;";   break;
+            case '>':  out += "&gt;";   break;
+            case '"':  out += "&quot;"; break;
+            case '\'': out += "&#39;";  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

+ 27 - 0
src/webserver/api/form_renderer.hpp

@@ -0,0 +1,27 @@
+#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

+ 37 - 0
src/webserver/api/webhook_controller.cpp

@@ -1,4 +1,5 @@
 #include "webhook_controller.hpp"
+#include "form_renderer.hpp"
 #include "logging/logger.hpp"
 #include "common/time_utils.hpp"
 #include "common/uuid.hpp"
@@ -58,6 +59,23 @@ void WebhookController::handleWebhook(const httplib::Request& req, httplib::Resp
         return;
     }
 
+    // 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;
+        }
+    }
+
     // Find matching HTTP trigger node for this method
     auto trigger_node = findTriggerNode(workflow, req.method);
 
@@ -336,6 +354,25 @@ std::optional<nlohmann::json> WebhookController::findTriggerNode(
     return std::nullopt;
 }
 
+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 std::make_optional(nlohmann::json(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");
+}
+
 bool WebhookController::validateApiKey(
     const httplib::Request& req,
     const nlohmann::json& config) {

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

@@ -29,6 +29,11 @@ private:
     bool validateApiKey(const httplib::Request& req, const nlohmann::json& config);
     bool validateBodySchema(const nlohmann::json& body, const nlohmann::json& schema, std::string& error);
 
+    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);
+
     void sendJson(httplib::Response& res, const nlohmann::json& data, int status = 200);
     void sendError(httplib::Response& res, const std::string& message, int status);
 

+ 21 - 0
tests/nodes/form-render-escaping.json

@@ -0,0 +1,21 @@
+{
+  "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": [
+      "&lt;script&gt;alert(1)&lt;/script&gt;",
+      "&lt;img src=x onerror=alert(2)&gt;",
+      "&amp;"
+    ],
+    "expectBodyExcludes": ["<script>alert(1)</script>", "<img src=x onerror"]
+  }
+}

+ 24 - 0
tests/nodes/form-render.json

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