diff --git a/README.md b/README.md index 4af1c79..229b485 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ This is the source code for catcrafts.net, a website built entirely in C++ using the Crafter.Graphics library. ```bash -crafter-build -r +./tools/dev.sh ``` This will compile the project and serve it locally on `http:://localhost:8080`. diff --git a/shared/interfaces/Catcrafts.Shared-Html.cppm b/shared/interfaces/Catcrafts.Shared-Html.cppm index 188cdf4..d820409 100644 --- a/shared/interfaces/Catcrafts.Shared-Html.cppm +++ b/shared/interfaces/Catcrafts.Shared-Html.cppm @@ -125,6 +125,18 @@ export SafeHtml Attr(std::string_view name, std::string_view value) { 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 @@ -133,16 +145,6 @@ export SafeHtml Attr(std::string_view name, std::string_view value) { // 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) { - auto 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; - }; - // 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. @@ -153,9 +155,9 @@ export SafeHtml Url(std::string_view attrName, std::string_view href) { } const bool safe = - startsWithNoCase(cleaned, "https://") - || startsWithNoCase(cleaned, "http://") - || startsWithNoCase(cleaned, "mailto:") + 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] == '/' @@ -204,4 +206,87 @@ 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 diff --git a/shared/interfaces/Catcrafts.Shared-Views.cppm b/shared/interfaces/Catcrafts.Shared-Views.cppm index a362df8..79ffe40 100644 --- a/shared/interfaces/Catcrafts.Shared-Views.cppm +++ b/shared/interfaces/Catcrafts.Shared-Views.cppm @@ -31,6 +31,7 @@ namespace Catcrafts::Views { using Html::SafeHtml; using Html::Escape; +using Html::Autolink; using Html::Format; using Html::Num; using Html::Raw; @@ -1115,7 +1116,10 @@ export RenderedPage RenderLegal(const LegalPage& lp) { for (const LegalSection& sec : lp.sections) { std::vector paras; for (const std::string& para : sec.body) { - paras.push_back(Format(R"(

{}

)", Escape(para))); + // Autolink, not Escape: the privacy notice points the reader at + // /analytics and at its own edit history, and a policy that says + // "go look for yourself" should not make them retype the address. + paras.push_back(Format(R"(

{}

)", Autolink(para))); } sections.push_back(Format( R"(