fixed links
All checks were successful
Deploy / build-deploy (push) Successful in 8m16s

This commit is contained in:
Jorijn van der Graaf 2026-08-08 20:04:36 +02:00
commit 2f5ccd272e
3 changed files with 107 additions and 16 deletions

View file

@ -3,7 +3,7 @@
This is the source code for catcrafts.net, a website built entirely in C++ using the Crafter.Graphics library. This is the source code for catcrafts.net, a website built entirely in C++ using the Crafter.Graphics library.
```bash ```bash
crafter-build -r ./tools/dev.sh
``` ```
This will compile the project and serve it locally on `http:://localhost:8080`. This will compile the project and serve it locally on `http:://localhost:8080`.

View file

@ -125,6 +125,18 @@ export SafeHtml Attr(std::string_view name, std::string_view value) {
return SafeHtml(SafeHtml::TrustedTag{}, std::move(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<char>(a - 'A' + 'a');
if (a != prefix[i]) return false;
}
return true;
}
// href/src emission with a scheme allowlist. // href/src emission with a scheme allowlist.
// //
// Escaping alone does not make a URL safe: `javascript:alert(1)` contains no // 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 // is replaced with "#" rather than dropped, so a bad link is visibly inert
// instead of silently vanishing from the markup. // instead of silently vanishing from the markup.
export SafeHtml Url(std::string_view attrName, std::string_view href) { 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<char>(a - 'A' + 'a');
if (a != prefix[i]) return false;
}
return true;
};
// Leading control characters and whitespace are stripped by browsers // Leading control characters and whitespace are stripped by browsers
// before scheme detection, so "java\tscript:" would slip past a naive // before scheme detection, so "java\tscript:" would slip past a naive
// prefix test. Strip them here first and validate what remains. // 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 = const bool safe =
startsWithNoCase(cleaned, "https://") StartsWithNoCase(cleaned, "https://")
|| startsWithNoCase(cleaned, "http://") || StartsWithNoCase(cleaned, "http://")
|| startsWithNoCase(cleaned, "mailto:") || StartsWithNoCase(cleaned, "mailto:")
// Site-relative, but NOT protocol-relative ("//evil.example" would // Site-relative, but NOT protocol-relative ("//evil.example" would
// leave the origin while looking like a path). // leave the origin while looking like a path).
|| (cleaned.size() >= 1 && cleaned[0] == '/' || (cleaned.size() >= 1 && cleaned[0] == '/'
@ -204,4 +206,87 @@ SafeHtml Format(std::format_string<AsString<Ts>...> fmt, const Ts&... args) {
return FormatImpl(fmt.get(), 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 <a>, 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<unsigned char>(c) <= 0x20
|| c == '<' || c == '>' || c == '"' || c == '\'' || c == '`';
};
std::vector<SafeHtml> 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"(<a{}>{}</a>)", Url("href", url), Escape(url)));
pos = end;
}
parts.push_back(Escape(text.substr(pos)));
return Join(parts);
}
} // namespace Catcrafts::Html } // namespace Catcrafts::Html

View file

@ -31,6 +31,7 @@ namespace Catcrafts::Views {
using Html::SafeHtml; using Html::SafeHtml;
using Html::Escape; using Html::Escape;
using Html::Autolink;
using Html::Format; using Html::Format;
using Html::Num; using Html::Num;
using Html::Raw; using Html::Raw;
@ -1115,7 +1116,10 @@ export RenderedPage RenderLegal(const LegalPage& lp) {
for (const LegalSection& sec : lp.sections) { for (const LegalSection& sec : lp.sections) {
std::vector<SafeHtml> paras; std::vector<SafeHtml> paras;
for (const std::string& para : sec.body) { for (const std::string& para : sec.body) {
paras.push_back(Format(R"(<p>{}</p>)", 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"(<p>{}</p>)", Autolink(para)));
} }
sections.push_back(Format( sections.push_back(Format(
R"(<section class="legal__section">)" R"(<section class="legal__section">)"
@ -1151,7 +1155,9 @@ export RenderedPage RenderAbout(const LegalPage& about) {
for (const LegalSection& sec : about.sections) { for (const LegalSection& sec : about.sections) {
std::vector<SafeHtml> paras; std::vector<SafeHtml> paras;
for (const std::string& para : sec.body) { for (const std::string& para : sec.body) {
paras.push_back(Format(R"(<p>{}</p>)", Escape(para))); // Same prose shape as the legal pages, so the same treatment —
// otherwise a URL added here later silently renders as dead text.
paras.push_back(Format(R"(<p>{}</p>)", Autolink(para)));
} }
sections.push_back(Format( sections.push_back(Format(
R"(<section class="legal__section">)" R"(<section class="legal__section">)"