catcrafts.net/shared/interfaces/Catcrafts.Shared-Route.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

212 lines
8 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.
*/
// URL -> route mapping, shared by both hosts.
//
// The wasm app resolves the route from window.location; the native server will
// resolve it from the request target. Same function, so a URL cannot mean one
// thing to a crawler and another to the app — which is the whole reason route
// parsing lives here instead of in an if/else chain per host.
export module Catcrafts.Shared:Route;
import std;
namespace Catcrafts {
export enum class RouteKind {
Home,
Projects,
Posts,
Demos, // /demos — the list
Demo, // /demos/<slug>
Shop, // /shop — the (currently single-item) product list
Product, // /shop/<slug>
Order, // /order/<token> — an order's status page
Invoice, // /order/<token>/invoice.md — the signed invoice download
Legal, // /legal/<slug> — privacy, imprint, terms
// The blog these routes replace. sitemap.xml advertised /blog and
// /blog/<slug>, and those URLs are in the wild — in shared links and in
// whatever the crawlers already have. They resolve to Posts and carry a
// canonical target so each host can do the right thing: the app rewrites
// the address bar, the server will answer 301.
LegacyBlog,
NotFound,
};
export struct Route {
RouteKind kind = RouteKind::NotFound;
std::string path; // normalised, no trailing slash (except "/")
std::string query; // raw, including leading '?', or empty
// Non-empty when the request should be canonicalised to a different URL.
std::string canonicalRedirect;
// The <slug> of /shop/<slug>, empty for every other route.
std::string slug;
};
// An order token is the entire capability to view that order — whoever has the
// URL sees the status page. 32 lowercase hex characters (128 bits from the
// server's CSPRNG), so it is unguessable; constraining the shape here means a
// probe like /order/../../etc never reaches a lookup.
export bool IsOrderToken(std::string_view s) {
if (s.size() != 32) return false;
for (const char c : s) {
const bool ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
if (!ok) return false;
}
return true;
}
// A slug appears in a URL, in an element id, and as a form field, so it is
// constrained rather than sanitised at each use: lowercase, digits and hyphens
// only, and bounded. Anything else is not a slug we ever generated.
export bool IsValidSlug(std::string_view s) {
if (s.empty() || s.size() > 64) return false;
for (const char c : s) {
const bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
if (!ok) return false;
}
// Leading/trailing hyphens and doubled hyphens are not produced by
// anything here, so reject them rather than carry ambiguity around.
if (s.front() == '-' || s.back() == '-') return false;
return s.find("--") == std::string_view::npos;
}
// Strip a trailing slash so "/projects/" and "/projects" are one route rather
// than two URLs with identical content — which would otherwise be a duplicate
// canonical for crawlers.
export std::string_view NormalisePath(std::string_view path) {
while (path.size() > 1 && path.back() == '/') path.remove_suffix(1);
if (path.empty()) return "/";
return path;
}
export Route ParseRoute(std::string_view path, std::string_view query = {}) {
Route r;
const std::string_view p = NormalisePath(path);
r.path = std::string(p);
r.query = std::string(query);
if (p == "/") { r.kind = RouteKind::Home; return r; }
if (p == "/projects") { r.kind = RouteKind::Projects; return r; }
if (p == "/posts") { r.kind = RouteKind::Posts; return r; }
if (p == "/demos") { r.kind = RouteKind::Demos; return r; }
if (p == "/shop") { r.kind = RouteKind::Shop; return r; }
// /order/<token>. The token is validated structurally here for the same
// reason slugs are: nothing downstream should ever see one it must
// re-validate. An invalid token is a plain 404 — indistinguishable from an
// unknown one, so the URL shape leaks nothing.
if (p.starts_with("/order/")) {
std::string_view rest = p.substr(7);
// /order/<token>/invoice.md downloads the signed invoice. Parsed here
// (not in the handler) so the token shape check happens exactly once.
RouteKind kind = RouteKind::Order;
if (rest.ends_with("/invoice.md")) {
rest.remove_suffix(11);
kind = RouteKind::Invoice;
}
if (IsOrderToken(rest)) {
r.kind = kind;
r.slug = std::string(rest);
return r;
}
r.kind = RouteKind::NotFound;
return r;
}
// /shop/<slug>. An invalid slug is a 404 rather than a lookup with a
// rejected key, so nothing downstream ever sees a slug it must re-validate.
if (p.starts_with("/shop/")) {
const std::string_view slug = p.substr(6);
if (IsValidSlug(slug)) {
r.kind = RouteKind::Product;
r.slug = std::string(slug);
return r;
}
r.kind = RouteKind::NotFound;
return r;
}
if (p.starts_with("/demos/")) {
const std::string_view slug = p.substr(7);
if (IsValidSlug(slug)) {
r.kind = RouteKind::Demo;
r.slug = std::string(slug);
return r;
}
r.kind = RouteKind::NotFound;
return r;
}
// /demo was the single-demo URL before there was a list. Keep it working:
// it was linked from the home page and may be in someone's history.
if (p == "/demo") {
r.kind = RouteKind::Demos;
r.canonicalRedirect = "/demos";
return r;
}
if (p.starts_with("/legal/")) {
const std::string_view slug = p.substr(7);
if (IsValidSlug(slug)) {
r.kind = RouteKind::Legal;
r.slug = std::string(slug);
return r;
}
r.kind = RouteKind::NotFound;
return r;
}
// /blog, /blog/anything -> /posts
if (p == "/blog" || p.starts_with("/blog/")) {
r.kind = RouteKind::LegacyBlog;
r.canonicalRedirect = "/posts";
return r;
}
r.kind = RouteKind::NotFound;
return r;
}
// The nav entries, in order. Shared so the header and the sitemap cannot
// disagree about what pages exist.
export struct NavItem {
std::string_view label;
std::string_view href;
RouteKind kind;
};
export std::span<const NavItem> NavItems() {
static constexpr std::array<NavItem, 5> items{{
{ "Home", "/", RouteKind::Home },
{ "Shop", "/shop", RouteKind::Shop },
{ "Projects", "/projects", RouteKind::Projects },
{ "Posts", "/posts", RouteKind::Posts },
{ "Demos", "/demos", RouteKind::Demos },
}};
return items;
}
// Routes that belong in sitemap.xml. NotFound and LegacyBlog are excluded:
// one isn't a page, the other is a redirect, and advertising either invites
// crawlers to index a URL that isn't canonical.
export std::span<const std::string_view> SitemapPaths() {
// /shop/<slug> entries are appended by the caller from the loaded product
// list — the sitemap has to reflect what actually exists, and a hardcoded
// slug list here would be one more thing to forget to update.
static constexpr std::array<std::string_view, 8> paths{
"/", "/shop", "/projects", "/posts", "/demos",
// Legal pages are indexable on purpose: they are trust signals, and a
// buyer looking for the returns policy before purchasing should be able
// to find it from a search engine.
"/legal/privacy", "/legal/terms", "/legal/imprint",
};
return paths;
}
} // namespace Catcrafts