catcrafts.net/shared/interfaces/Catcrafts.Shared-Form.cppm
Jorijn van der Graaf 934c94cb5c
Some checks failed
Deploy / build-deploy (push) Failing after 4m56s
rewrite
2026-08-05 04:18:37 +02:00

295 lines
12 KiB
C++

/*
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