/* catcrafts.net Copyright (C) 2026 Catcrafts The source code of this website is made available for viewing purposes only. No permission is granted to copy, modify, distribute, or create derivative works. */ // application/x-www-form-urlencoded parsing and field validation. // // Lives in Catcrafts.Shared rather than the server because it is pure string // work with no I/O, which means it can be exercised on the host with real // assertions instead of only against a live socket. Checkout will reuse all of // it. // // Two decisions worth stating up front: // // * Validation returns a list of per-field errors rather than throwing or // returning the first failure. A form that reports one problem at a time // makes the user resubmit repeatedly to discover the rest. // // * Every limit is explicit and every field is length-capped. Input arrives // from anyone on the internet, and "how long can this be" is not a // question to leave to whatever the caller happens to allocate. export module Catcrafts.Shared:Form; import std; namespace Catcrafts::Form { // Hard cap on a whole request body. Well above any legitimate submission here; // the point is that an unbounded body cannot make the server allocate without // limit before parsing even starts. export inline constexpr std::size_t kMaxBodyBytes = 16 * 1024; // Per-field cap, applied after decoding. export inline constexpr std::size_t kMaxFieldBytes = 1024; export class Fields { public: // First value for `name`, or empty. Duplicates keep the first: a repeated // field in a submission is either a bug or someone probing, and taking the // first is the predictable choice. std::string_view Get(std::string_view name) const { for (const auto& [k, v] : pairs_) { if (k == name) return v; } return {}; } bool Has(std::string_view name) const { for (const auto& [k, v] : pairs_) { if (k == name) return true; } return false; } std::size_t Size() const noexcept { return pairs_.size(); } void Add(std::string key, std::string value) { pairs_.emplace_back(std::move(key), std::move(value)); } private: std::vector> pairs_; }; // Percent-decode one component, treating '+' as space per the // urlencoded serialisation. A malformed escape is passed through literally // rather than dropped, so a stray '%' survives a round trip instead of // silently mangling the value. export std::string PercentDecode(std::string_view in) { auto hex = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; if (c >= 'A' && c <= 'F') return c - 'A' + 10; return -1; }; std::string out; out.reserve(in.size()); for (std::size_t i = 0; i < in.size(); ++i) { const char c = in[i]; if (c == '+') { out.push_back(' '); } else if (c == '%' && i + 2 < in.size()) { const int hi = hex(in[i + 1]); const int lo = hex(in[i + 2]); if (hi >= 0 && lo >= 0) { out.push_back(static_cast(hi * 16 + lo)); i += 2; } else { out.push_back(c); } } else { out.push_back(c); } } return out; } // Parse a urlencoded body. Oversized bodies yield nothing rather than a partial // parse — a truncated form is not something to act on. export std::optional ParseUrlEncoded(std::string_view body) { if (body.size() > kMaxBodyBytes) return std::nullopt; Fields out; while (!body.empty()) { const std::size_t amp = body.find('&'); std::string_view pair = body.substr(0, amp); body = (amp == std::string_view::npos) ? std::string_view{} : body.substr(amp + 1); if (pair.empty()) continue; // tolerate "a=1&&b=2" const std::size_t eq = pair.find('='); std::string key = PercentDecode(eq == std::string_view::npos ? pair : pair.substr(0, eq)); std::string val = eq == std::string_view::npos ? std::string{} : PercentDecode(pair.substr(eq + 1)); if (key.empty() || key.size() > kMaxFieldBytes || val.size() > kMaxFieldBytes) { return std::nullopt; } out.Add(std::move(key), std::move(val)); } return out; } // ── validation ──────────────────────────────────────────────────────── export struct FieldError { std::string field; std::string message; }; // Trim ASCII whitespace. Deliberately not locale-aware: these are machine // fields (an address, a country code), not prose. export std::string_view Trim(std::string_view s) { while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r' || s.front() == '\n')) s.remove_prefix(1); while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r' || s.back() == '\n')) s.remove_suffix(1); return s; } // Deliberately permissive email check. // // Not a regex from a blog post and not an RFC 5322 parser. Fully validating an // address is impossible without sending to it, and every strict validator in // the wild rejects addresses that genuinely work (new TLDs, tagged local parts, // unicode domains). So this rejects only what is definitely not an address — // no '@', nothing before or after it, a dotless domain, whitespace, control // characters — and lets delivery be the real test. export bool LooksLikeEmail(std::string_view s) { if (s.size() < 3 || s.size() > 254) return false; const std::size_t at = s.find('@'); if (at == std::string_view::npos || at == 0 || at + 1 >= s.size()) return false; // Exactly one '@': a second one is unambiguously malformed. if (s.find('@', at + 1) != std::string_view::npos) return false; const std::string_view domain = s.substr(at + 1); const std::size_t dot = domain.find('.'); if (dot == std::string_view::npos || dot == 0 || dot + 1 >= domain.size()) return false; for (const char c : s) { if (static_cast(c) <= 0x20 || c == 0x7F) return false; if (c == ',' || c == ';' || c == '<' || c == '>' || c == '"' || c == '\\') return false; } return true; } // ISO 3166-1 alpha-2, uppercased. Shape only — whether we actually ship there // is a policy question answered elsewhere, not a validation one. export bool LooksLikeCountryCode(std::string_view s) { if (s.size() != 2) return false; for (const char c : s) { if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) return false; } return true; } export std::string Upper(std::string_view s) { std::string out(s); for (char& c : out) { if (c >= 'a' && c <= 'z') c = static_cast(c - 'a' + 'A'); } return out; } // ── checkout ────────────────────────────────────────────────────────── // What the buy form collects: enough to ship a parcel and send an invoice, and // nothing more. No account, no phone number, no marketing checkbox. The amount // is deliberately NOT a field — money never comes from the client; the server // computes it from the product record and the country. export struct Checkout { std::string email; std::string name; // recipient, as it should appear on the label std::string street; // street + number, one line std::string postal; std::string city; std::string country; // ISO-3166-1 alpha-2, uppercased std::string color; // variant slug; whether it EXISTS is the handler's // check against the catalogue, not a shape check std::int64_t quantity = 1; }; // A technical sanity bound, not a business cap — bulk orders are welcome. // It exists because the integer math (here and mirrored in the preview // script) and a bunq payment link both need SOME ceiling, and an order of a // hundred phones deserves an email conversation more than a form submit. export inline constexpr std::int64_t kMaxQuantity = 99; export struct CheckoutResult { Checkout value; std::vector errors; bool Ok() const { return errors.empty(); } }; // Validate a submitted checkout. // // The honeypot: the form renders a field that a human never sees and never // fills. Anything in it means an automated submission, which is reported as a // generic failure rather than "you tripped the honeypot" — naming the trap // teaches the next bot how to avoid it. export CheckoutResult ValidateCheckout(const Fields& f) { CheckoutResult r; if (!Trim(f.Get("website")).empty()) { r.errors.push_back({ "", "Submission rejected." }); return r; } // Every field is echoed back into `value` even when it fails validation, so // the caller can re-render the form with what the visitor typed. Discarding // a rejected field means making them retype the one thing they got wrong, // which is how a submission gets abandoned. `value` is only ever *stored* // when Ok() is true, so an invalid value cannot leak into the record. const std::string_view email = Trim(f.Get("email")); r.value.email = std::string(email); if (email.empty()) { r.errors.push_back({ "email", "An email address is required — order updates go there." }); } else if (!LooksLikeEmail(email)) { r.errors.push_back({ "email", "That doesn't look like an email address." }); } // A required free-text field: reject empty and oversize, accept everything // else. Names, streets and cities worldwide defeat any stricter shape check // — validating them harder only rejects real addresses. auto requiredText = [&](std::string_view fieldName, std::string& into, std::size_t maxLen, std::string_view emptyMsg) { const std::string_view v = Trim(f.Get(fieldName)); into = std::string(v); if (v.empty()) { r.errors.push_back({ std::string(fieldName), std::string(emptyMsg) }); } else if (v.size() > maxLen) { r.errors.push_back({ std::string(fieldName), "Too long." }); } }; requiredText("name", r.value.name, 120, "A recipient name is required — it goes on the label."); requiredText("street", r.value.street, 200, "A street address is required."); requiredText("postal", r.value.postal, 20, "A postal code is required."); requiredText("city", r.value.city, 120, "A city is required."); const std::string_view country = Trim(f.Get("country")); // Normalise on the way in so a valid code is stored uppercase; an invalid // one is echoed as typed so the visitor recognises their own input. r.value.country = LooksLikeCountryCode(country) ? Upper(country) : std::string(country); if (country.empty()) { r.errors.push_back({ "country", "Pick a country — it decides shipping and VAT treatment." }); } else if (!LooksLikeCountryCode(country)) { r.errors.push_back({ "country", "Country must be a two-letter code." }); } // Colour: shape only (slug-ish, bounded). Whether it names a variant that // exists — and what it costs — is the catalogue's answer, in the handler. const std::string_view color = Trim(f.Get("color")); r.value.color = std::string(color); if (color.size() > 32) { r.errors.push_back({ "color", "That is not one of the colours." }); } // Quantity: a small positive integer, nothing else. Absent means 1 (the // no-JS form default); anything unparseable or out of range is rejected // rather than clamped — silently changing how many phones someone buys is // worse than asking again. const std::string_view qty = Trim(f.Get("quantity")); if (qty.empty()) { r.value.quantity = 1; } else { std::int64_t parsed = 0; auto [ptr, ec] = std::from_chars(qty.data(), qty.data() + qty.size(), parsed); if (ec != std::errc{} || ptr != qty.data() + qty.size() || parsed < 1 || parsed > kMaxQuantity) { r.value.quantity = 1; r.errors.push_back({ "quantity", std::format("Quantity must be between 1 and {}.", kMaxQuantity) }); } else { r.value.quantity = parsed; } } return r; } } // namespace Catcrafts::Form