"Short text", "email" => "Email", "tel" => "Phone", "url" => "Website", "textarea" => "Long text", "number" => "Number", "select" => "Dropdown", "multiselect" => "Multiple selection", "radio" => "Single choice", "checkboxes" => "Checkbox group", "checkbox" => "Checkbox", "consent" => "Consent", "date" => "Date", "time" => "Time", "datetime-local" => "Date & time", "file" => "File upload", "rating" => "Rating", "range" => "Slider", "calculation" => "Calculation", "repeater" => "Repeating rows", "hidden" => "Hidden value", "heading" => "Section heading", "paragraph" => "Information", "divider" => "Divider", ]; } function flatten(array $schema): array { $fields = []; foreach ($schema["pages"] ?? [] as $page) { foreach ($page["fields"] ?? [] as $field) { $fields[] = $field; } } return $fields; } function form_defaults(): array { return [ "email_templates" => email_templates([]), "confirmation_email_field" => "", "conversion_ga4" => false, "conversion_ga4_event" => "generate_lead", "conversion_gtm" => false, "conversion_ads" => "", "conversion_meta" => false, "conversion_value" => "", "conversion_currency" => "INR", "framework" => "native", "submit_label" => "Send enquiry", "next_label" => "Continue", "back_label" => "Back", "success_title" => "Thank you. Your message is with us.", "success_message" => "We will get back to you as soon as we can.", "redirect_url" => "", "notify" => true, "recipients" => settings()["recipient"], "email_subject" => "New enquiry: {form_name}", "autoresponder" => false, "autoresponder_subject" => "We received your message", "autoresponder_message" => "Thank you for contacting us. We have received your message and will be in touch.", "webhook_url" => "", "webhook_secret" => "", "hubspot_enabled" => false, "hubspot_email_field" => "", "hubspot_firstname_field" => "", "hubspot_lastname_field" => "", "hubspot_phone_field" => "", "hubspot_company_field" => "", "hubspot_website_field" => "", "hubspot_jobtitle_field" => "", "retention_days" => 0, "show_progress" => true, "submit_limit" => 0, "open_at" => "", "close_at" => "", "closed_message" => "This form is not accepting responses at the moment.", "login_required" => false, "routes" => [], "accent" => "#44484D", "button_hover" => "#DD222C", "density" => "comfortable", ]; } function sanitize_form_url(string $url, bool $external = true): string { $url = trim($url); if ( str_starts_with($url, "/") && !str_starts_with($url, "//") && !str_contains($url, "\\") ) { return esc_url_raw($url); } if ( $external && wp_http_validate_url($url) && wp_parse_url($url, PHP_URL_SCHEME) === "https" ) { return esc_url_raw($url); } return ""; } function clean_conditions($input, array $previous) { if (!is_array($input) || empty($input["rules"])) { return ["mode" => "all", "rules" => []]; } $rules = []; foreach (array_slice((array) $input["rules"], 0, 10) as $rule) { $id = sanitize_key($rule["field"] ?? ""); if ( !isset($previous[$id]) || in_array( $previous[$id], ["file", "repeater", "heading", "paragraph", "divider"], true, ) ) { return problem("Conditions must refer to an earlier answer field."); } $operator = $rule["operator"] ?? "equals"; if ( !in_array( $operator, [ "equals", "not_equals", "contains", "is_empty", "is_not_empty", "greater", "less", ], true, ) ) { return problem("Unknown conditional rule."); } $rules[] = [ "field" => $id, "operator" => $operator, "value" => sanitize_text_field( substr((string) ($rule["value"] ?? ""), 0, 500), ), ]; } return [ "mode" => ($input["mode"] ?? "") === "any" ? "any" : "all", "rules" => $rules, ]; } function clean_form(array $input) { $name = sanitize_text_field( substr((string) ($input["name"] ?? ""), 0, 190), ); if ($name === "") { return problem("Give the form a name."); } $status = in_array( $input["status"] ?? "", ["active", "draft", "archived"], true, ) ? $input["status"] : "draft"; $raw_pages = $input["schema"]["pages"] ?? []; if ( !is_array($raw_pages) || count($raw_pages) < 1 || count($raw_pages) > 12 ) { return problem("A form needs between one and twelve pages."); } $pages = []; $previous = []; $page_ids = []; $count = 0; foreach ($raw_pages as $p => $page) { $page_id = sanitize_key($page["id"] ?? "page_" . ($p + 1)); if (!$page_id || isset($page_ids[$page_id])) { return problem("Each page needs a unique ID."); } $page_ids[$page_id] = true; $page_conditions = clean_conditions( $page["conditions"] ?? [], $previous, ); if (is_wp_error($page_conditions)) { return $page_conditions; } $fields = []; foreach ((array) ($page["fields"] ?? []) as $raw) { if (++$count > 100) { return problem("A form can contain up to 100 fields."); } $id = sanitize_key($raw["id"] ?? ""); $type = $raw["type"] ?? "text"; if (!$id || strlen($id) > 80 || isset($previous[$id])) { return problem( "Each field needs a unique ID of up to 80 characters.", ); } if (!isset(field_types()[$type])) { return problem("Unknown field type."); } $conditions = clean_conditions($raw["conditions"] ?? [], $previous); if (is_wp_error($conditions)) { return $conditions; } $field = [ "id" => $id, "type" => $type, "label" => sanitize_text_field( substr((string) ($raw["label"] ?? ""), 0, 255), ), "help" => sanitize_textarea_field( substr((string) ($raw["help"] ?? ""), 0, 1000), ), "placeholder" => sanitize_text_field( substr((string) ($raw["placeholder"] ?? ""), 0, 255), ), "required" => !in_array( $type, [ "hidden", "calculation", "heading", "paragraph", "divider", ], true, ) && !empty($raw["required"]), "width" => ($raw["width"] ?? "") === "half" ? "half" : "full", "default" => sanitize_textarea_field( substr((string) ($raw["default"] ?? ""), 0, 5000), ), "conditions" => $conditions, "role" => in_array( $raw["role"] ?? "", ["name", "email", "company", "subject"], true, ) ? $raw["role"] : "", "autocomplete" => in_array( $raw["autocomplete"] ?? "", [ "", "name", "given-name", "family-name", "email", "tel", "organization", "url", "street-address", "postal-code", "off", ], true, ) ? $raw["autocomplete"] ?? "" : "", "query_key" => sanitize_key( substr((string) ($raw["query_key"] ?? ""), 0, 60), ), "link_url" => sanitize_form_url( (string) ($raw["link_url"] ?? ""), ), "link_label" => sanitize_text_field( substr((string) ($raw["link_label"] ?? ""), 0, 100), ), "min_length" => max( 0, min(10000, (int) ($raw["min_length"] ?? 0)), ), "max_length" => max( 1, min( 10000, (int) ($raw["max_length"] ?? ($type === "textarea" ? 5000 : 255)), ), ), ]; if ( !$field["label"] && !in_array($type, ["hidden", "divider"], true) ) { return problem("Every visible field needs a label."); } if ($field["min_length"] > $field["max_length"]) { return problem("Minimum length cannot exceed maximum length."); } foreach (["min", "max"] as $key) { $field[$key] = isset($raw[$key]) && $raw[$key] !== "" && is_numeric($raw[$key]) ? (float) $raw[$key] : null; } if ( $field["min"] !== null && $field["max"] !== null && $field["min"] > $field["max"] ) { return problem("Minimum value cannot exceed maximum value."); } $field["step"] = is_numeric($raw["step"] ?? "") && $raw["step"] > 0 ? min(1000000, (float) $raw["step"]) : 1; $field["options"] = []; foreach ( array_slice((array) ($raw["options"] ?? []), 0, 100) as $option ) { if (is_string($option)) { $option = ["label" => $option, "value" => $option]; } $label = sanitize_text_field( substr((string) ($option["label"] ?? ""), 0, 190), ); $value = sanitize_text_field( substr((string) ($option["value"] ?? $label), 0, 190), ); if ( $label !== "" && $value !== "" && !in_array( $value, array_column($field["options"], "value"), true, ) ) { $field["options"][] = [ "label" => $label, "value" => $value, ]; } } if ( in_array( $type, ["select", "multiselect", "radio", "checkboxes"], true, ) && !$field["options"] ) { return problem("Choice fields need at least one option."); } $field["extensions"] = array_values( array_intersect( array_map( "strtolower", (array) ($raw["extensions"] ?? [ "pdf", "jpg", "png", "webp", ]), ), [ "pdf", "jpg", "jpeg", "png", "webp", "txt", "csv", "docx", "xlsx", ], ), ); if ($type === "file" && !$field["extensions"]) { return problem("Choose at least one supported file type."); } $field["max_mb"] = max(1, min(5, (int) ($raw["max_mb"] ?? 5))); $field["max_files"] = max( 1, min(3, (int) ($raw["max_files"] ?? 1)), ); $field["max_rows"] = max( 1, min(20, (int) ($raw["max_rows"] ?? 10)), ); $field["columns"] = []; foreach ( array_slice( (array) ($raw["columns"] ?? [ ["id" => "item", "label" => "Item", "type" => "text"], ]), 0, 5, ) as $i => $column ) { $field["columns"][] = [ "id" => "c" . $i, "label" => sanitize_text_field( substr((string) ($column["label"] ?? "Value"), 0, 80), ), "type" => in_array( $column["type"] ?? "", ["text", "number", "email"], true, ) ? $column["type"] : "text", ]; } $field["formula"] = sanitize_text_field( substr((string) ($raw["formula"] ?? ""), 0, 500), ); $field["decimals"] = max(0, min(6, (int) ($raw["decimals"] ?? 2))); if ($type === "calculation") { preg_match_all("/\{([a-z0-9_-]+)\}/", $field["formula"], $refs); foreach ($refs[1] as $ref) { if ( !isset($previous[$ref]) || !in_array( $previous[$ref], ["number", "range", "rating", "calculation"], true, ) ) { return problem( "Calculations can use only earlier numeric fields.", ); } } $formula = preg_replace( "/\{[a-z0-9_-]+\}/", "1", $field["formula"], ); if (calculate($formula) === null) { return problem( "Use a valid calculation with numbers, field IDs in braces, +, -, *, / and parentheses.", ); } } $fields[] = $field; $previous[$id] = $type; } $pages[] = [ "id" => $page_id, "title" => sanitize_text_field( substr((string) ($page["title"] ?? "Page " . ($p + 1)), 0, 190), ), "description" => sanitize_textarea_field( substr((string) ($page["description"] ?? ""), 0, 1000), ), "conditions" => $page_conditions, "fields" => $fields, ]; } if (!$count) { return problem("Add at least one field."); } $s = form_defaults(); $raw = (array) ($input["settings"] ?? []); $s["framework"] = isset(frameworks()[$raw["framework"] ?? ""]) ? $raw["framework"] : "native"; foreach ( [ "submit_label", "next_label", "back_label", "success_title", "email_subject", "autoresponder_subject", "closed_message", ] as $key ) { $s[$key] = sanitize_text_field( substr((string) ($raw[$key] ?? $s[$key]), 0, 255), ); } foreach (["success_message", "autoresponder_message"] as $key) { $s[$key] = sanitize_textarea_field( substr((string) ($raw[$key] ?? $s[$key]), 0, 5000), ); } foreach ( [ "notify", "autoresponder", "show_progress", "login_required", "hubspot_enabled", ] as $key ) { $s[$key] = array_key_exists($key, $raw) ? (bool) $raw[$key] : $s[$key]; } $emails = array_values( array_filter( array_map( "trim", preg_split( '/[,;\n]+/', (string) ($raw["recipients"] ?? $s["recipients"]), ), ), ), ); if ( count($emails) > 10 || array_filter($emails, static fn($email) => !is_email($email)) ) { return problem( "Use valid notification email addresses, separated by commas.", ); } if ($s["notify"] && !$emails) { return problem( "Add a notification recipient or turn notifications off.", ); } $s["recipients"] = implode(", ", $emails); $s["redirect_url"] = sanitize_form_url( (string) ($raw["redirect_url"] ?? ""), false, ); if (!empty($raw["redirect_url"]) && !$s["redirect_url"]) { return problem( "The confirmation redirect must be a relative path on this website.", ); } $s["webhook_url"] = sanitize_form_url((string) ($raw["webhook_url"] ?? "")); if ($s["webhook_url"] && str_starts_with($s["webhook_url"], "/")) { return problem("Webhooks need a public HTTPS URL."); } if (!empty($raw["webhook_url"]) && !$s["webhook_url"]) { return problem("Use a public HTTPS webhook URL."); } $s["webhook_secret"] = sanitize_text_field( substr((string) ($raw["webhook_secret"] ?? ""), 0, 190), ); $all_fields = flatten(["pages" => $pages]); $fields_by_id = array_column($all_fields, null, "id"); foreach ( [ "hubspot_email_field", "hubspot_firstname_field", "hubspot_lastname_field", "hubspot_phone_field", "hubspot_company_field", "hubspot_website_field", "hubspot_jobtitle_field", ] as $key ) { $s[$key] = sanitize_key($raw[$key] ?? ""); if ( $s[$key] && (!isset($fields_by_id[$s[$key]]) || in_array( $fields_by_id[$s[$key]]["type"], ["heading", "paragraph", "divider", "file", "repeater"], true, )) ) { return problem("Choose valid form fields for the HubSpot mapping."); } } if ($s["hubspot_enabled"]) { if (!$s["hubspot_email_field"]) { foreach ($all_fields as $field) { if ($field["type"] === "email" && $field["role"] === "email") { $s["hubspot_email_field"] = $field["id"]; break; } } } $email_field = $fields_by_id[$s["hubspot_email_field"]] ?? null; if (!$email_field || $email_field["type"] !== "email") { return problem( "Choose an email field before enabling HubSpot contact sync.", ); } if (empty($email_field["required"])) { return problem( "The HubSpot email field must be required so every synced contact has an email address.", ); } } $s["retention_days"] = max( 0, min(3650, (int) ($raw["retention_days"] ?? 0)), ); $s["submit_limit"] = max( 0, min(1000000, (int) ($raw["submit_limit"] ?? 0)), ); foreach (["open_at", "close_at"] as $key) { $date = (string) ($raw[$key] ?? ""); $s[$key] = $date && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/', $date) && strtotime($date) ? $date : ""; } if ($s["open_at"] && $s["close_at"] && $s["open_at"] >= $s["close_at"]) { return problem("The closing time must be after the opening time."); } foreach (["accent", "button_hover"] as $key) { $s[$key] = sanitize_hex_color($raw[$key] ?? "") ?: $s[$key]; } $s["density"] = ($raw["density"] ?? "") === "compact" ? "compact" : "comfortable"; foreach (["conversion_ga4", "conversion_gtm", "conversion_meta"] as $key) { if (array_key_exists($key, $raw)) { $s[$key] = (bool) $raw[$key]; } } $s["conversion_ga4_event"] = preg_match( '/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/D', $raw["conversion_ga4_event"] ?? "", ) ? $raw["conversion_ga4_event"] : "generate_lead"; $s["conversion_ads"] = preg_match( '/^AW-[0-9]+\/[a-zA-Z0-9_-]+$/D', $raw["conversion_ads"] ?? "", ) ? $raw["conversion_ads"] : ""; if (!empty($raw["conversion_ads"]) && !$s["conversion_ads"]) { return problem( "Enter a Google Ads destination such as AW-123456789/Label.", ); } $s["conversion_value"] = is_numeric($raw["conversion_value"] ?? "") ? max(0, min(1e9, (float) $raw["conversion_value"])) : ""; $s["conversion_currency"] = preg_match( '/^[A-Z]{3}$/D', $raw["conversion_currency"] ?? "", ) ? $raw["conversion_currency"] : "INR"; foreach (array_slice((array) ($raw["routes"] ?? []), 0, 10) as $route) { if ( !isset($previous[$route["field"] ?? ""]) || !is_email($route["recipient"] ?? "") ) { return problem( "Notification routes need an existing field and a valid email.", ); } $s["routes"][] = [ "field" => sanitize_key($route["field"]), "value" => sanitize_text_field( substr((string) ($route["value"] ?? ""), 0, 190), ), "recipient" => sanitize_email($route["recipient"]), ]; } $s["confirmation_email_field"] = sanitize_key( $raw["confirmation_email_field"] ?? "", ); $email_fields = array_values( array_filter( flatten(["pages" => $pages]), static fn($f) => $f["type"] === "email", ), ); if ( $s["confirmation_email_field"] && !in_array( $s["confirmation_email_field"], array_column($email_fields, "id"), true, ) ) { return problem( "Choose an existing email field for the visitor confirmation.", ); } if ( $s["autoresponder"] && !$s["confirmation_email_field"] && !array_filter($email_fields, static fn($f) => $f["role"] === "email") ) { return problem( "Choose an email field before enabling visitor confirmations.", ); } $email_input = $s; $email_input["email_templates"] = $raw["email_templates"] ?? []; $templates = clean_email_templates( $email_input, flatten(["pages" => $pages]), ); if (is_wp_error($templates)) { return $templates; } $s["email_templates"] = $templates; return [ "name" => $name, "status" => $status, "schema" => ["pages" => $pages], "settings" => $s, ]; } function matches(array $conditions, array $values): bool { if (empty($conditions["rules"])) { return true; } $results = []; foreach ($conditions["rules"] as $rule) { $answer = $values[$rule["field"]] ?? ""; $target = (string) $rule["value"]; $array = is_array($answer) ? array_map("strval", $answer) : [(string) $answer]; $text = implode(" ", $array); $results[] = match ($rule["operator"]) { "equals" => in_array($target, $array, true), "not_equals" => !in_array($target, $array, true), "contains" => str_contains(strtolower($text), strtolower($target)), "is_empty" => $text === "", "is_not_empty" => $text !== "", "greater" => is_numeric($text) && is_numeric($target) && (float) $text > (float) $target, "less" => is_numeric($text) && is_numeric($target) && (float) $text < (float) $target, default => false, }; } return ($conditions["mode"] ?? "") === "any" ? in_array(true, $results, true) : !in_array(false, $results, true); } /** Small arithmetic parser. No eval, code execution, functions or property access. */ function calculate(string $expression): ?float { $expression = preg_replace("/\s+/", "", $expression); if (!$expression || strlen($expression) > 2000) { return null; } preg_match_all("/\d+(?:\.\d+)?|\.\d+|[()+*\/-]/", $expression, $m); $tokens = $m[0]; if (implode("", $tokens) !== $expression) { return null; } $position = 0; $factor = $term = $sum = null; $factor = static function () use ( &$factor, &$sum, &$position, $tokens, ): ?float { $token = $tokens[$position] ?? null; if ($token === "+" || $token === "-") { $position++; $value = $factor(); return $value === null ? null : ($token === "-" ? -$value : $value); } if ($token === "(") { $position++; $value = $sum(); if (($tokens[$position++] ?? "") !== ")") { return null; } return $value; } if ($token === null || !is_numeric($token)) { return null; } $position++; return (float) $token; }; $term = static function () use (&$factor, &$position, $tokens): ?float { $left = $factor(); if ($left === null) { return null; } while (in_array($tokens[$position] ?? "", ["*", "/"], true)) { $op = $tokens[$position++]; $right = $factor(); if ($right === null || ($op === "/" && $right == 0)) { return null; } $left = $op === "*" ? $left * $right : $left / $right; } return $left; }; $sum = static function () use (&$term, &$position, $tokens): ?float { $left = $term(); if ($left === null) { return null; } while (in_array($tokens[$position] ?? "", ["+", "-"], true)) { $op = $tokens[$position++]; $right = $term(); if ($right === null) { return null; } $left = $op === "+" ? $left + $right : $left - $right; } return $left; }; $value = $sum(); return $position === count($tokens) && $value !== null && is_finite($value) && abs($value) <= 1e12 ? $value : null; } function contact_template(): array { $copy = function_exists("noesis_content") ? \noesis_content("contact") : []; $field = static fn($id, $type, $label, $required = false, $extra = []) => [ "id" => $id, "type" => $type, "label" => $label, "required" => $required, ] + $extra; $fields = [ $field("name", "text", $copy["nameLabel"] ?? "Your name", true, [ "width" => "half", "autocomplete" => "name", "role" => "name", "max_length" => 120, ]), $field("email", "email", $copy["emailLabel"] ?? "Email address", true, [ "width" => "half", "autocomplete" => "email", "role" => "email", "max_length" => 190, ]), $field("company", "text", $copy["companyLabel"] ?? "Company", false, [ "width" => "half", "autocomplete" => "organization", "role" => "company", "max_length" => 190, ]), $field( "service", "select", $copy["serviceLabel"] ?? "How can we help?", true, [ "width" => "half", "role" => "subject", "options" => $copy["services"] ?? [ "Magento Development", "WordPress Development", "AI Development", "Something else", ], ], ), $field( "message", "textarea", $copy["messageLabel"] ?? "Tell us what you have in mind", true, [ "placeholder" => $copy["placeholder"] ?? "", "help" => $copy["help"] ?? "", "min_length" => 20, "max_length" => 5000, ], ), $field( "consent", "consent", $copy["consent"] ?? "I agree that you may use these details to respond to my enquiry.", true, [ "link_url" => $copy["privacyURL"] ?? "/privacy/", "link_label" => $copy["privacyLabel"] ?? "Privacy policy", ], ), ]; return [ "name" => "Website contact", "status" => "active", "schema" => [ "pages" => [ [ "id" => "contact", "title" => "Tell us about your project", "fields" => $fields, ], ], ], "settings" => array_replace(form_defaults(), [ "submit_label" => $copy["submitLabel"] ?? "Send enquiry", "success_title" => $copy["thanksTitle"] ?? "Thank you. Your message is with us.", "success_message" => $copy["thanksText"] ?? "We will get back to you soon.", ]), ]; }