2026-08-05 04:18:37 +02:00
|
|
|
/*
|
|
|
|
|
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,
|
2026-08-05 07:05:00 +02:00
|
|
|
About, // /about — the person behind the company
|
2026-08-05 04:18:37 +02:00
|
|
|
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; }
|
2026-08-05 07:05:00 +02:00
|
|
|
if (p == "/about") { r.kind = RouteKind::About; return r; }
|
2026-08-05 04:18:37 +02:00
|
|
|
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() {
|
2026-08-05 07:05:00 +02:00
|
|
|
static constexpr std::array<NavItem, 6> items{{
|
2026-08-05 04:18:37 +02:00
|
|
|
{ "Home", "/", RouteKind::Home },
|
|
|
|
|
{ "Shop", "/shop", RouteKind::Shop },
|
|
|
|
|
{ "Projects", "/projects", RouteKind::Projects },
|
|
|
|
|
{ "Posts", "/posts", RouteKind::Posts },
|
|
|
|
|
{ "Demos", "/demos", RouteKind::Demos },
|
2026-08-05 07:05:00 +02:00
|
|
|
{ "About", "/about", RouteKind::About },
|
2026-08-05 04:18:37 +02:00
|
|
|
}};
|
|
|
|
|
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.
|
2026-08-05 07:05:00 +02:00
|
|
|
static constexpr std::array<std::string_view, 9> paths{
|
|
|
|
|
"/", "/about", "/shop", "/projects", "/posts", "/demos",
|
2026-08-05 04:18:37 +02:00
|
|
|
// 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
|