|
|
@@ -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 += "&"; 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
|