/* 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; import :Money; // country policy: which destinations the shop sells to 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 by Money::SellsTo in ValidateCheckout below. 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; // The donation amount in cents, set only by ValidateDonation. This is the // ONE amount that ever arrives from the client — a donation has no // catalogue price to compute from — and it is bounded here and re-derived // nowhere, so the handler charges exactly what was validated. Zero for // every goods checkout, where money still never comes from the client. std::int64_t amountMinor = 0; std::string payChoice; // kPayBank | kPayCrypto; empty means the form did // not offer a choice, which the handler reads as // bank. Whether the chosen rail is CONFIGURED is // the handler's check, like the colour: this is a // shape check and nothing more. }; // How the buyer's money moves. Two KINDS of money movement, not two brand // names: which rail serves each is the server's configuration, and writing // the kind (rather than "mollie"/"eurc") into the form and the ledger means // swapping a rail cannot retroactively rewrite what a buyer picked. // // These strings are the wire format — they travel in the form post and land // verbatim in the order log — so they are defined once, here, where both the // form that emits them and the server that stores them can see them. export inline constexpr std::string_view kPayBank = "bank"; export inline constexpr std::string_view kPayCrypto = "crypto"; // The outer sanity bound on the parsed integer — NOT the quantity a buyer can // actually order. That limit is physical and per-destination: one order is one // parcel, so what fits is the heaviest carrier bracket for that country // divided by the boxed unit weight (Money::MaxUnitsFor). The form renders the // best case across destinations as its `max`, the preview narrows it the // moment a country is typed, and the handler enforces the real one. // // This constant survives because validation has to reject a hostile // "quantity=99999999999" before any of that arithmetic runs. export inline constexpr std::int64_t kMaxQuantity = 99; // What a buyer outside Money::ShippableCountries is told. // // One sentence for every refusal that is not sanctions, because from the buyer's // side they are all one fact: their country has not been cleared. Merging them is // also the honest shape — the old split between "we are not registered there", // "the handset would not work there" and "our insurer excludes it" described // Catcrafts' internal reasons, not anything the buyer can act on. // // Names the reason as regulatory rather than commercial, because a Dutch shop // that plainly posts worldwide would otherwise read as arbitrary, or worse as // quietly declining someone. The truth is duller and better: selling a phone into // a country means meeting that country's rules first, and doing it in the wrong // order is the illegal part. // // No date is promised. Which country comes next depends on which one people ask // for, and an invented timeline is worse than none — so the address is the point // of the last clause. // // One definition, three renderings: this error, the note above the form, and the // on-page total preview, so the page can never encourage an order the server // will refuse. export inline constexpr std::string_view kRegulatoryMessage = "Catcrafts can't ship there. Selling a phone into a country means meeting " "that country's own rules first — recycling schemes, radio approval, import " "registration — and only a few are cleared so far. Email " "orders@catcrafts.net if you would like yours looked at next."; // The standing note above the buy form, and the one place the policy is stated // POSITIVELY. With an allow-list that is the only useful phrasing: "we ship to // these five" tells a visitor in one glance what "we cannot ship to two hundred // others" never would, and it stops someone filling in a whole address before // the field error tells them no. // // Prose rather than generated from Money::ShippableCountries because that array // holds ISO codes and a buyer should not have to decode "NZ". The duplication is // real, so ShouldComputeMoney asserts the array's LENGTH — add a country and that // test fails, pointing here. Update both or neither. export inline constexpr std::string_view kShipsToMessage = "Catcrafts currently ships to the Netherlands, Switzerland, Serbia, " "Montenegro, Albania, Kosovo, Georgia, Australia, Hong Kong and " "Singapore. Other " "countries are being worked through one at a time; email " "orders@catcrafts.net if you would like yours looked at next."; // The sanctions refusal, in different words on purpose: kRegulatoryMessage // describes paperwork that could be done, this states a prohibition that cannot. // Naming the reason here rather than only on the terms page, because "sanctions" // is the whole answer: nothing about the shop could change it, and a buyer told // only "no" would rightly ask why. export inline constexpr std::string_view kSanctionsMessage = "Catcrafts cannot sell or ship to Russia, Belarus or North Korea: " "EU sanctions prohibit exporting consumer electronics there."; // The two shipping refusals, worded once. // // Since the shop stopped carrying its own rate table, the carrier's coverage // IS the shop's coverage: no bracket for a country means no price exists to // charge, and a parcel above every bracket is one the carrier will not take. // Both refuse rather than guess — a quote the shop cannot honour is worse than // a no. The uncovered-country refusal is final: no carrier service means the // shop does not ship there, and offering to arrange it by hand would promise // exactly the ad-hoc export the shop decided not to do. The too-heavy refusal // still names the quantity that WOULD fit, because that buyer has an order the // shop can take — just not in one parcel. // // Kept as {cc}/{n} templates rather than format strings because they have two // consumers: the handler fills them for its field errors, and the buy page // hands them to the total preview verbatim to fill client-side. Same sentence // before and after the submit, from one definition. export inline constexpr std::string_view kNoShippingTemplate = "No carrier rate for {cc} is available, so Catcrafts can't ship there."; export inline constexpr std::string_view kTooHeavyTemplate = "That is more than fits one parcel to {cc} — up to {n} per order. For a " "larger order email orders@catcrafts.net."; // The degenerate case: a destination whose heaviest bracket does not even carry // one boxed unit. "Order fewer" is not advice when fewer is zero, so it gets // its own sentence — and like the uncovered country, it is a final no: what // the carrier can't take, the shop doesn't ship. export inline constexpr std::string_view kTooHeavyNoneTemplate = "A parcel this heavy can't be shipped to {cc} by any rate available, " "so this order can't be placed."; export std::string FillShipMessage(std::string_view tmpl, std::string_view cc, std::int64_t n) { std::string out(tmpl); for (const auto& [token, value] : std::initializer_list>{ { "{cc}", std::string(cc) }, { "{n}", std::to_string(n) } }) { for (std::size_t at = out.find(token); at != std::string::npos; at = out.find(token, at + value.size())) { out.replace(at, token.size(), value); } } return out; } export std::string NoShippingMessage(std::string_view cc) { return FillShipMessage(kNoShippingTemplate, cc, 0); } // `maxUnits` is what the heaviest bracket to that country actually fits, and // may be zero. export std::string TooHeavyMessage(std::string_view cc, std::int64_t maxUnits) { return FillShipMessage(maxUnits > 0 ? kTooHeavyTemplate : kTooHeavyNoneTemplate, cc, maxUnits); } 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." }); } else if (Money::IsSanctioned(r.value.country)) { // Sanctions are checked first because both gates deny and only the words // differ: this one says the law forbids the sale, not that the shop has // not got round to that country yet. r.errors.push_back({ "country", std::string(kSanctionsMessage) }); } else if (!Money::ShipsTo(r.value.country)) { // Everything not on the shipping list. The refusal happens here, in // validation, rather than at the payment step: no order record, no // payment link, nothing charged to undo. r.errors.push_back({ "country", std::string(kRegulatoryMessage) }); } // 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." }); } // How they want to pay. Absent means the form did not render the choice // (only one rail configured, or the no-JS fallback page), which the // handler resolves to bank. // // An unrecognised value is rejected rather than defaulted: the two ways it // can happen are a tampered post and a form that has drifted from this // validator, and quietly charging someone through a rail they did not pick // is the wrong answer to both. const std::string_view pay = Trim(f.Get("pay")); r.value.payChoice = std::string(pay); if (!pay.empty() && pay != kPayBank && pay != kPayCrypto) { r.errors.push_back({ "pay", "Pick one of the payment methods." }); } // 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; } // ── donations ───────────────────────────────────────────────────────── // The bounds on a donation, in cents. The floor keeps the amount above the // payment rails' own minimums and the fees that would eat a smaller gift; the // ceiling is an anti-fat-finger and anti-abuse bound — anyone genuinely // wanting to give more is an email conversation, not a form post. export inline constexpr std::int64_t kMinDonationMinor = 100; // €1 export inline constexpr std::int64_t kMaxDonationMinor = 1'000'000; // €10,000 // Exact decimal-euros-to-cents parsing: "25" -> 2500, "12.50" -> 1250, and a // comma decimal mark is accepted because half the donors here will type one. // Anything else — sign, exponent, a third decimal, stray text — is nullopt // rather than a guess. Integer arithmetic throughout; like every money path // in this codebase, no float ever touches the amount. export std::optional ParseEuroAmountToMinor(std::string_view s) { if (s.empty() || s.size() > 10) return std::nullopt; std::size_t mark = std::string_view::npos; for (std::size_t i = 0; i < s.size(); ++i) { if (s[i] == '.' || s[i] == ',') { if (mark != std::string_view::npos) return std::nullopt; mark = i; } else if (s[i] < '0' || s[i] > '9') { return std::nullopt; } } const std::string_view whole = s.substr(0, mark); const std::string_view frac = mark == std::string_view::npos ? std::string_view{} : s.substr(mark + 1); if (whole.empty() || frac.size() > 2) return std::nullopt; std::int64_t euros = 0; auto [p, ec] = std::from_chars(whole.data(), whole.data() + whole.size(), euros); if (ec != std::errc{} || p != whole.data() + whole.size()) return std::nullopt; std::int64_t cents = 0; if (!frac.empty()) { auto [fp, fec] = std::from_chars(frac.data(), frac.data() + frac.size(), cents); if (fec != std::errc{} || fp != frac.data() + frac.size()) return std::nullopt; if (frac.size() == 1) cents *= 10; // "2.5" is €2.50, not €2.05 } return euros * 100 + cents; } // Validate a submitted donation. Deliberately NOT ValidateCheckout with // fields waived: a donation ships nothing, so no name or address is even // asked for — collecting them would break the privacy notice's "what // fulfilling it requires" rule, not just pad the form. // // Email is OPTIONAL, the one shape difference worth a comment: the order // page's capability URL is already the receipt, so identity is only needed // if the donor wants the confirmation emailed. An empty email means no email, // never an error. export CheckoutResult ValidateDonation(const Fields& f) { CheckoutResult r; r.value.quantity = 1; // a donation is one line, always // The same honeypot as checkout, reported just as namelessly. if (!Trim(f.Get("website")).empty()) { r.errors.push_back({ "", "Submission rejected." }); return r; } const std::string_view email = Trim(f.Get("email")); r.value.email = std::string(email); if (!email.empty() && !LooksLikeEmail(email)) { r.errors.push_back({ "email", "That doesn't look like an email address." }); } // The amount: present, parseable, in bounds. Out of range is rejected // rather than clamped — silently moving someone's gift is worse than // asking again, same rule as checkout's quantity. const std::string_view amount = Trim(f.Get("amount")); if (amount.empty()) { r.errors.push_back({ "amount", "Name an amount — any euro amount you like." }); } else if (const auto minor = ParseEuroAmountToMinor(amount); !minor) { r.errors.push_back({ "amount", "That doesn't look like a euro amount." }); } else if (*minor < kMinDonationMinor || *minor > kMaxDonationMinor) { r.errors.push_back({ "amount", std::format("Donations are accepted from {} to {} — for more, " "email info@catcrafts.net.", Money::FormatEuro(kMinDonationMinor), Money::FormatEuro(kMaxDonationMinor)) }); } else { r.value.amountMinor = *minor; } // The payment choice, exactly as checkout reads it: absent means the form // offered no choice and the handler takes the bank rail; an unrecognised // word is a tampered post or a drifted form, and both are refused. const std::string_view pay = Trim(f.Get("pay")); r.value.payChoice = std::string(pay); if (!pay.empty() && pay != kPayBank && pay != kPayCrypto) { r.errors.push_back({ "pay", "Pick one of the payment methods." }); } return r; } } // namespace Catcrafts::Form