295 lines
12 KiB
Text
295 lines
12 KiB
Text
|
|
/*
|
||
|
|
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<std::pair<std::string, std::string>> 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<char>(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<Fields> 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<unsigned char>(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<char>(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<FieldError> 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
|