This commit is contained in:
parent
fb2f6079cc
commit
934c94cb5c
50 changed files with 10464 additions and 758 deletions
212
shared/interfaces/Catcrafts.Shared-Route.cppm
Normal file
212
shared/interfaces/Catcrafts.Shared-Route.cppm
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/*
|
||||
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
|
||||
Loading…
Reference in a new issue