This commit is contained in:
parent
ae30ac69f7
commit
2f5ccd272e
3 changed files with 107 additions and 16 deletions
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">)"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue