/* 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. */ // HTML construction with escaping enforced by the type system. // // The problem this solves: Crafter.Graphics has no setAttribute-shaped API for // most of what a page needs, so markup is built as strings and handed to // SetInnerHTML. Any product name, post title or user-supplied field // interpolated into one of those strings is an XSS sink, and "remember to // escape" is not a strategy that survives a codebase. // // So: SafeHtml is an opaque wrapper whose constructors from string types are // DELETED. The only ways to obtain one are Escape() (escapes), Num()/Money() // (can't contain markup), Url() (scheme-allowlisted), and Raw() (the single // audited escape hatch). Format() then accepts only SafeHtml arguments, so // // Html::Format("

{}

", post.title) // std::string -> COMPILE ERROR // Html::Format("

{}

", Escape(title)) // ok // // The failure mode for forgetting to escape is a build failure, not a stored // cross-site-scripting bug. export module Catcrafts.Shared:Html; import std; namespace Catcrafts::Html { export class SafeHtml { public: SafeHtml() = default; // Deleted so no string type can become SafeHtml implicitly. Without // these, `SafeHtml h = userInput;` would silently compile and the whole // guarantee would be decorative. SafeHtml(const char*) = delete; SafeHtml(std::string) = delete; SafeHtml(std::string_view) = delete; // Str() returns a reference (not a copy) because Format() feeds these // to std::make_format_args, which in C++ binds Args&... and therefore // needs lvalues. const std::string& Str() const noexcept { return v_; } std::string_view View() const noexcept { return v_; } bool Empty() const noexcept { return v_.empty(); } std::size_t Size() const noexcept { return v_.size(); } SafeHtml& operator+=(const SafeHtml& r) { v_ += r.v_; return *this; } friend SafeHtml operator+(SafeHtml l, const SafeHtml& r) { l += r; return l; } private: // Private tagged ctor: the ONLY path from a raw string into the type. // Every friend below is a function that has established the string is // safe to emit, either by escaping it or by generating it itself. struct TrustedTag {}; SafeHtml(TrustedTag, std::string v) : v_(std::move(v)) {} std::string v_; friend SafeHtml Escape(std::string_view); friend SafeHtml Raw(std::string_view); friend SafeHtml Num(std::int64_t); friend SafeHtml Attr(std::string_view, std::string_view); friend SafeHtml Url(std::string_view, std::string_view); friend SafeHtml Join(std::span, const SafeHtml&); template friend SafeHtml FormatImpl(std::string_view, const Ts&...); }; // Escape for both text and attribute contexts in a single pass. // // Quotes are escaped even though they are harmless in text content, so that // ONE function is correct in every context. The alternative — a text escaper // and an attribute escaper — means every call site is a chance to pick wrong, // which is the bug this module exists to prevent. export SafeHtml Escape(std::string_view text) { std::string out; out.reserve(text.size() + text.size() / 8); for (const char c : text) { switch (c) { case '&': out += "&"; break; case '<': out += "<"; break; case '>': out += ">"; break; case '"': out += """; break; case '\'': out += "'"; break; default: out += c; break; } } return SafeHtml(SafeHtml::TrustedTag{}, std::move(out)); } // Integers can't carry markup, so they pass through unescaped. export SafeHtml Num(std::int64_t n) { return SafeHtml(SafeHtml::TrustedTag{}, std::to_string(n)); } // The single escape hatch. Every call is a claim that the argument is markup // this codebase generated. Kept greppable and lint-gated to a small allowlist // of files — if it starts appearing in view code, the discipline has failed. export SafeHtml Raw(std::string_view trustedMarkup) { return SafeHtml(SafeHtml::TrustedTag{}, std::string(trustedMarkup)); } // `name="escaped-value"`, including the leading space, or empty when the // value is empty — so optional attributes compose without leaving stray // whitespace or a bare `alt=""` where none was wanted. // // The name is validated rather than escaped: an attribute name is never // user data in this codebase, and silently emitting a mangled one would // hide a bug. An invalid name yields nothing. export SafeHtml Attr(std::string_view name, std::string_view value) { for (const char c : name) { const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == ':'; if (!ok) return SafeHtml{}; } if (name.empty() || value.empty()) return SafeHtml{}; std::string out = " "; out += name; out += "=\""; out += Escape(value).Str(); out += '"'; return SafeHtml(SafeHtml::TrustedTag{}, std::move(out)); } // Case-insensitive prefix test, for scheme detection. Not exported: schemes // are the only thing in this module that needs it. bool StartsWithNoCase(std::string_view s, std::string_view prefix) { if (s.size() < prefix.size()) return false; for (std::size_t i = 0; i < prefix.size(); ++i) { char a = s[i]; if (a >= 'A' && a <= 'Z') a = static_cast(a - 'A' + 'a'); if (a != prefix[i]) return false; } return true; } // href/src emission with a scheme allowlist. // // Escaping alone does not make a URL safe: `javascript:alert(1)` contains no // character that needs escaping, so an escaped-but-unvalidated href is still // script execution. Anything not clearly http/https/mailto or site-relative // is replaced with "#" rather than dropped, so a bad link is visibly inert // instead of silently vanishing from the markup. export SafeHtml Url(std::string_view attrName, std::string_view href) { // Leading control characters and whitespace are stripped by browsers // before scheme detection, so "java\tscript:" would slip past a naive // prefix test. Strip them here first and validate what remains. std::string cleaned; cleaned.reserve(href.size()); for (const char c : href) { if (static_cast(c) > 0x20) cleaned += c; } const bool safe = StartsWithNoCase(cleaned, "https://") || StartsWithNoCase(cleaned, "http://") || StartsWithNoCase(cleaned, "mailto:") // Site-relative, but NOT protocol-relative ("//evil.example" would // leave the origin while looking like a path). || (cleaned.size() >= 1 && cleaned[0] == '/' && !(cleaned.size() >= 2 && cleaned[1] == '/')) || (!cleaned.empty() && cleaned[0] == '#'); return Attr(attrName, safe ? std::string_view(cleaned) : std::string_view("#")); } export SafeHtml Join(std::span parts, const SafeHtml& sep = {}) { std::string out; std::size_t total = 0; for (const SafeHtml& p : parts) total += p.Size() + sep.Size(); out.reserve(total); bool first = true; for (const SafeHtml& p : parts) { if (!first) out += sep.Str(); out += p.Str(); first = false; } return SafeHtml(SafeHtml::TrustedTag{}, std::move(out)); } // Only SafeHtml may be interpolated. export template concept Safe = std::same_as, SafeHtml>; template SafeHtml FormatImpl(std::string_view fmt, const Ts&... args) { return SafeHtml(SafeHtml::TrustedTag{}, std::vformat(fmt, std::make_format_args(args.Str()...))); } // Maps each SafeHtml parameter to std::string for the format-string check, // so std::format_string validates placeholder count and syntax at compile // time against the real argument list. template using AsString = std::string; // The gate. Two properties, both enforced by the signature: // * std::format_string means the template must be a compile-time constant, // so a runtime-assembled template can't be smuggled in; // * `Safe... Ts` means every argument is already SafeHtml, so a bare // std::string, const char*, int or string_view fails to compile. export template SafeHtml Format(std::format_string...> fmt, const Ts&... args) { return FormatImpl(fmt.get(), args...); } // Escape(), plus bare URLs in the text become anchors. // // The legal and about pages are stored as plain sentences rather than // markdown (see LegalSection), so a URL written into one rendered as inert // text the reader had to select and retype. Everything is still escaped; the // only addition is that http/https runs are wrapped in , with the href // going through Url() so an autolinked address passes exactly the same scheme // check as a hand-written one. Built from Escape/Url/Format/Join alone — no // Raw(), so this adds no new trusted path into SafeHtml. // // An explicit scheme is required. "example.com" mid-sentence is a guess that // would eventually link something that is not a URL; "https://example.com" is // the author saying outright that this is a link. export SafeHtml Autolink(std::string_view text) { // What ends a URL in prose. Everything else is taken greedily and then // trimmed below, which is the only way to get "(see https://x/y)" right. auto terminates = [](char c) { return static_cast(c) <= 0x20 || c == '<' || c == '>' || c == '"' || c == '\'' || c == '`'; }; std::vector parts; std::size_t pos = 0; while (pos < text.size()) { std::size_t at = text.size(); for (std::size_t i = pos; i < text.size(); ++i) { if (!StartsWithNoCase(text.substr(i), "https://") && !StartsWithNoCase(text.substr(i), "http://")) continue; // A scheme has to start a word: "shttps://" is not a link, and // neither is the tail of some longer token. Tested against the // real previous character, not `pos` — two URLs run together // ("...a.comhttps://b") must not link the second. if (i > 0) { const char prev = text[i - 1]; const bool word = (prev >= 'a' && prev <= 'z') || (prev >= 'A' && prev <= 'Z') || (prev >= '0' && prev <= '9'); if (word) continue; } at = i; break; } if (at == text.size()) break; std::size_t end = at; while (end < text.size() && !terminates(text[end])) ++end; // Trailing punctuation belongs to the sentence, not to the URL: // "...at https://catcrafts.net/analytics." must not link the period. // A closing bracket is kept only when the URL itself opened one. while (end > at) { const char last = text[end - 1]; if (last == '.' || last == ',' || last == ';' || last == ':' || last == '!' || last == '?') { --end; continue; } if (last == ')' || last == ']' || last == '}') { const char open = last == ')' ? '(' : last == ']' ? '[' : '{'; const std::string_view sofar = text.substr(at, end - at); if (std::ranges::count(sofar, open) >= std::ranges::count(sofar, last)) break; --end; continue; } break; } // A scheme with nothing after it is not a link. Emit it as text and // resume past it, so a degenerate "https://" can't stall the loop. const std::string_view url = text.substr(at, end - at); const std::size_t host = url.find("//"); if (host == std::string_view::npos || host + 2 >= url.size()) { parts.push_back(Escape(text.substr(pos, end - pos))); pos = end; continue; } parts.push_back(Escape(text.substr(pos, at - pos))); parts.push_back(Format(R"({})", Url("href", url), Escape(url))); pos = end; } parts.push_back(Escape(text.substr(pos))); return Join(parts); } } // namespace Catcrafts::Html