245 lines
13 KiB
C++
245 lines
13 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.
|
||
|
|
*/
|
||
|
|
|
||
|
|
// Post media and post pages over real HTTP. The media IS the content of these
|
||
|
|
// posts (screen recordings of the work), and it must come from our own
|
||
|
|
// origin: the privacy notice states that everything the browser loads comes
|
||
|
|
// from catcrafts.net, and a third-party embed would send every visitor's IP
|
||
|
|
// to whichever instance hosted the file.
|
||
|
|
|
||
|
|
import std;
|
||
|
|
import Catcrafts.E2eHarness;
|
||
|
|
|
||
|
|
using namespace Catcrafts::E2e;
|
||
|
|
|
||
|
|
namespace {
|
||
|
|
|
||
|
|
// Media loaded from a third party — `poster` is in the list because a video
|
||
|
|
// poster is fetched on page load exactly like an <img> src is.
|
||
|
|
const std::regex kThirdPartyMedia(
|
||
|
|
R"((src|srcset|href|poster)="https?://[^"]*\.(mp4|webm|webp|avif|png|jpe?g|gif))");
|
||
|
|
|
||
|
|
std::vector<std::string> PostPathsFromSitemap(TestServer& srv, std::size_t limit) {
|
||
|
|
const std::string sitemap = srv.Body("/sitemap.xml");
|
||
|
|
const std::regex slug(R"(/posts/[a-z0-9-]+)");
|
||
|
|
std::vector<std::string> out;
|
||
|
|
for (auto it = std::sregex_iterator(sitemap.begin(), sitemap.end(), slug);
|
||
|
|
it != std::sregex_iterator() && out.size() < limit; ++it) {
|
||
|
|
if (std::ranges::find(out, it->str()) == out.end()) out.push_back(it->str());
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
} // namespace
|
||
|
|
|
||
|
|
int main(int argc, char** argv) {
|
||
|
|
TestServer srv(argv[1], 8214);
|
||
|
|
|
||
|
|
const std::string posts = srv.Body("/posts");
|
||
|
|
|
||
|
|
// ── post media ────────────────────────────────────────────────────
|
||
|
|
Check(std::regex_search(posts, std::regex(R"(<(img|video) class="post-media__item")")),
|
||
|
|
"/posts embeds its media");
|
||
|
|
Check(!std::regex_search(posts, kThirdPartyMedia),
|
||
|
|
"/posts loads no media from a third party");
|
||
|
|
// Dimensions prevent layout shift as each file arrives. Needs ffprobe at
|
||
|
|
// fetch time — a build host without it produces no dimensions at all,
|
||
|
|
// which is what this catches.
|
||
|
|
Check(std::regex_search(posts, std::regex(
|
||
|
|
R"(<img class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+")")),
|
||
|
|
"images carry width/height");
|
||
|
|
// Videos too. This assertion exists because they silently lost theirs:
|
||
|
|
// ffprobe appends an empty CSV field for some files, so parsing
|
||
|
|
// `width,height` as one joined string yielded a height of "480x" and the
|
||
|
|
// guard discarded both.
|
||
|
|
Check(std::regex_search(posts, std::regex(
|
||
|
|
R"(<video class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+")")),
|
||
|
|
"videos carry width/height");
|
||
|
|
// A poster is the frame shown before anyone presses play, and these posts
|
||
|
|
// ARE their video. "At least one" rather than "every one": an instance
|
||
|
|
// that generated no thumbnail is a legitimate empty poster, but zero
|
||
|
|
// posters across every video means the fetch/mirror/render chain broke.
|
||
|
|
Check(std::regex_search(posts, std::regex(
|
||
|
|
R"(<video class="post-media__item"[^>]*poster="/media/)")),
|
||
|
|
"videos carry a locally-hosted poster");
|
||
|
|
// A video offering an AV1 <source> must offer an H.264 one after it: the
|
||
|
|
// codecs parameter is what lets a browser without AV1 skip to a file it
|
||
|
|
// can play. Conditional — a build whose posts carry no AV1 has nothing to
|
||
|
|
// check.
|
||
|
|
if (posts.find("codecs=av01") != std::string::npos) {
|
||
|
|
Check(std::regex_search(posts, std::regex(
|
||
|
|
R"(<source src="/media/[^"]*\.h264\.mp4" type="video/mp4">)")),
|
||
|
|
"AV1 videos carry an H.264 fallback source");
|
||
|
|
}
|
||
|
|
// preload="metadata", not auto: several 5 MB recordings must not all
|
||
|
|
// download on page load.
|
||
|
|
Check(posts.find("preload=\"metadata\"") != std::string::npos,
|
||
|
|
"video does not preload its whole body");
|
||
|
|
|
||
|
|
// ── post pages ────────────────────────────────────────────────────
|
||
|
|
// The post page is where the body lives, and the body is the reason the
|
||
|
|
// site has anything for a search engine to index beyond a list of links
|
||
|
|
// off it. Its slug is data, so take one from the page rather than
|
||
|
|
// hardcoding a title that will be wrong the week after it is written.
|
||
|
|
std::string postPath;
|
||
|
|
{
|
||
|
|
std::smatch m;
|
||
|
|
if (std::regex_search(posts, m, std::regex(R"lit(href="(/posts/[a-z0-9-]+)")lit"))) {
|
||
|
|
postPath = m[1].str();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Check(!postPath.empty(), "/posts links a post page; a body exists somewhere");
|
||
|
|
if (!postPath.empty()) {
|
||
|
|
srv.CheckStatus(postPath, "200");
|
||
|
|
srv.BodyHas("/posts", "Read the full post", "/posts offers the full post");
|
||
|
|
// And it trails the excerpt, immediately after the ellipsis the
|
||
|
|
// truncation left, rather than sitting as its own row below the
|
||
|
|
// media. The excerpt is escaped text, so nothing but the link can put
|
||
|
|
// a '<' between the two.
|
||
|
|
Check(std::regex_search(posts, std::regex(
|
||
|
|
R"(<p class="post-card__excerpt">[^<]*<a class="link-more" href="/posts/)")),
|
||
|
|
"read-more trails the excerpt");
|
||
|
|
|
||
|
|
const std::string page = srv.Body(postPath);
|
||
|
|
Check(page.find("<div class=\"post-body\">") != std::string::npos,
|
||
|
|
"post page carries the rendered body");
|
||
|
|
// Rendered, not dumped: a body that reached the page as literal
|
||
|
|
// Markdown would show its own asterisks and hashes to the reader and
|
||
|
|
// to a crawler.
|
||
|
|
Check(std::regex_search(page, std::regex(R"(<(p|h2|h3|h4|ul|ol|blockquote|pre)>)")),
|
||
|
|
"post body is real markup, not literal Markdown");
|
||
|
|
// The canonical points here, not at the instance. That is the entire
|
||
|
|
// SEO argument for hosting the body: two copies of the text exist,
|
||
|
|
// and this says which one is the original as far as this site is
|
||
|
|
// concerned.
|
||
|
|
Check(page.find("rel=\"canonical\" href=\"https://catcrafts.net/posts/")
|
||
|
|
!= std::string::npos,
|
||
|
|
"post page is its own canonical");
|
||
|
|
Check(page.find("\"@type\":\"BlogPosting\"") != std::string::npos,
|
||
|
|
"post page carries BlogPosting JSON-LD");
|
||
|
|
Check(page.find("\"@id\":\"https://catcrafts.net/#organization\"") != std::string::npos,
|
||
|
|
"post JSON-LD joins the organization node");
|
||
|
|
Check(page.find("\"@id\":\"https://catcrafts.net/about#person\"") != std::string::npos,
|
||
|
|
"post JSON-LD joins the founder node");
|
||
|
|
Check(page.find("property=\"og:type\" content=\"article\"") != std::string::npos,
|
||
|
|
"post page is an article to og:");
|
||
|
|
// Hosting the body does not mirror the discussion; the thread is
|
||
|
|
// still one click away and is still where the comments are.
|
||
|
|
Check(std::regex_search(page, std::regex(R"lit(href="https://[a-z0-9.-]+/post/[0-9]+")lit")),
|
||
|
|
"post page still links its thread");
|
||
|
|
// The body is prose, not an application.
|
||
|
|
Check(page.find("<script>") == std::string::npos,
|
||
|
|
"post page ships no executable script");
|
||
|
|
Check(page.find("<base") == std::string::npos, "post page has no base tag");
|
||
|
|
|
||
|
|
// Every inline image and video a body embeds is mirrored, exactly
|
||
|
|
// like a card's media — "everything comes from catcrafts.net" covers
|
||
|
|
// href as well as src, so a body linking a .webp on someone else's
|
||
|
|
// instance is the same leak as embedding one.
|
||
|
|
const std::vector<std::string> pages = PostPathsFromSitemap(srv, 20);
|
||
|
|
std::vector<std::string> referencedMedia;
|
||
|
|
for (const std::string& pg : pages) {
|
||
|
|
const std::string body = srv.Body(pg);
|
||
|
|
Check(!std::regex_search(body, kThirdPartyMedia),
|
||
|
|
std::format("{} loads no media from a third party", pg));
|
||
|
|
const std::regex mediaRef(R"lit((src|srcset)="(/media/[^"]+)")lit");
|
||
|
|
for (auto it = std::sregex_iterator(body.begin(), body.end(), mediaRef);
|
||
|
|
it != std::sregex_iterator(); ++it) {
|
||
|
|
const std::string f = (*it)[2].str();
|
||
|
|
if (std::ranges::find(referencedMedia, f) == referencedMedia.end()) {
|
||
|
|
referencedMedia.push_back(f);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The image format ladder: AVIF first, the mirrored original next,
|
||
|
|
// and a PNG on the <img> underneath, so exactly one file is fetched
|
||
|
|
// and every browser can read one of them. Order is the whole point —
|
||
|
|
// a browser takes the first source it understands.
|
||
|
|
if (page.find("<picture>") != std::string::npos) {
|
||
|
|
Check(std::regex_search(page, std::regex(
|
||
|
|
R"(<picture><source srcset="/media/[^"]+\.avif" type="image/avif">)")),
|
||
|
|
"inline images lead with an AVIF source");
|
||
|
|
Check(std::regex_search(page, std::regex(
|
||
|
|
R"lit(<img class="post-media__item"[^>]*src="/media/[^"]+\.png")lit")),
|
||
|
|
"inline images fall back to a PNG the img itself points at");
|
||
|
|
// Every tier has to be a file that exists, or the ladder serves a
|
||
|
|
// 404 to whichever browsers pick that rung — precisely the set of
|
||
|
|
// browsers nobody testing this site is using. The files are
|
||
|
|
// served by Caddy rather than by this server, so they are checked
|
||
|
|
// on disk. NOT hardcoded to ./media: CI points the mirror at the
|
||
|
|
// persistent mount instead (E2E_MEDIA_DIR) so the copies survive
|
||
|
|
// a deploy.
|
||
|
|
std::filesystem::path mediaDir;
|
||
|
|
if (const char* env = std::getenv("E2E_MEDIA_DIR"); env && *env) {
|
||
|
|
mediaDir = env;
|
||
|
|
} else {
|
||
|
|
for (std::string_view d : { "media", "/deploy-app/media" }) {
|
||
|
|
if (std::filesystem::is_directory(d)) { mediaDir = d; break; }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (mediaDir.empty()) {
|
||
|
|
std::println("note: no media directory found; set E2E_MEDIA_DIR — "
|
||
|
|
"the media-files-exist check did not run");
|
||
|
|
} else {
|
||
|
|
std::size_t missing = 0;
|
||
|
|
for (const std::string& f : referencedMedia) {
|
||
|
|
if (!std::filesystem::exists(mediaDir / f.substr(std::string_view("/media/").size()))) {
|
||
|
|
++missing;
|
||
|
|
// Bounded: a wrong directory makes EVERY file missing,
|
||
|
|
// and a hundred identical lines buries the one fact
|
||
|
|
// that matters.
|
||
|
|
if (missing <= 5) std::println(std::cerr, " missing: {}", f);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Check(missing == 0,
|
||
|
|
std::format("every referenced media file is in {}", mediaDir.string()),
|
||
|
|
std::format("{} referenced file(s) missing", missing));
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
std::println("note: no <picture> on {} — ffmpeg absent at mirror time? "
|
||
|
|
"the format-ladder checks did not run", postPath);
|
||
|
|
}
|
||
|
|
// Inline screenshots get dimensions from the sidecar list
|
||
|
|
// fetch-media.sh writes, because Markdown syntax has nowhere to carry
|
||
|
|
// them. Conditional: a post whose body embeds nothing has nothing to
|
||
|
|
// check.
|
||
|
|
if (page.find("<img class=\"post-media__item\"") != std::string::npos) {
|
||
|
|
Check(std::regex_search(page, std::regex(
|
||
|
|
R"(<img class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+")")),
|
||
|
|
"inline body images carry width/height");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The sitemap has to advertise the pages, or hosting the bodies buys
|
||
|
|
// nothing.
|
||
|
|
Check(std::regex_search(srv.Body("/sitemap.xml"), std::regex(
|
||
|
|
R"(<loc>https://catcrafts\.net/posts/[a-z0-9-]+</loc>)")),
|
||
|
|
"sitemap lists the post pages");
|
||
|
|
|
||
|
|
// Every outbound thread link is a real permalink: absolute https, on some
|
||
|
|
// instance, pointing at a numeric post id. A resolution failure
|
||
|
|
// legitimately falls back to the author's copy, so this checks the shape
|
||
|
|
// rather than naming a host.
|
||
|
|
{
|
||
|
|
const std::regex permalink(R"lit(href="https://[a-z0-9.-]+/post/[0-9]+")lit");
|
||
|
|
std::size_t links = 0;
|
||
|
|
for (auto it = std::sregex_iterator(posts.begin(), posts.end(), permalink);
|
||
|
|
it != std::sregex_iterator(); ++it) {
|
||
|
|
++links;
|
||
|
|
}
|
||
|
|
Check(links > 0, "posts list links its threads by permalink");
|
||
|
|
// Nothing should link a post by a bare id or a relative path — that
|
||
|
|
// would mean a permalink was rendered without its origin and silently
|
||
|
|
// resolves to catcrafts.net.
|
||
|
|
Check(!std::regex_search(posts, std::regex(R"lit(href="/post/[0-9]+")lit")),
|
||
|
|
"no thread link resolves to catcrafts.net");
|
||
|
|
}
|
||
|
|
|
||
|
|
return Finish();
|
||
|
|
}
|