//! Repositories' websites, deployed with `knotless repo site` and answered on //! their own domain by an appview that follows the reference PDS. #[path = "../../knotless/tests/common/mod.rs"] mod common; use common::*; use knotless_serve::index::Index; use knotless_serve::sites::{Sites, hosted}; use knotless_serve::{App, router}; use std::time::Duration; const DOMAIN: &str = "knotless.page"; const HOME: &str = "https://pages.example"; /// What a request to `base` as `host` got: its status, headers and body. fn get( base: &str, host: &str, path: &str, headers: &[(&str, &str)], ) -> (u16, reqwest::header::HeaderMap, String) { let client = reqwest::blocking::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); let mut req = client .get(format!("{base}{path}")) .header("x-forwarded-host", host); for (name, value) in headers { req = req.header(*name, *value); } let res = req.send().unwrap(); let (status, headers) = (res.status().as_u16(), res.headers().clone()); (status, headers, res.text().unwrap()) } #[test] fn sites_are_served_on_their_domain() { let mut s = Sandbox::new(); let handle = s.account.handle.clone(); let did = s.account.did.clone(); s.set("KNOTLESS_IDENTIFIER", &handle); s.set("KNOTLESS_CHUNK_SIZE", "1000"); let rt = tokio::runtime::Runtime::new().unwrap(); let listener = rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let index = Index::open(&s.path("index.db")).unwrap(); let app = App::build(ctx(&s), Duration::ZERO, None, Some(index)); let sites = Sites::new(app.clone(), DOMAIN.into(), format!("{HOME}/")); let routes = hosted(router(app.clone()), sites); rt.spawn(async move { axum::serve(listener, routes).await.unwrap() }); let ws = net().pds.replace("http://", "ws://"); rt.spawn(knotless_serve::firehose::watch(app.clone(), ws)); let url = s.url("site"); let work = s.work("w", 1); s.git(&work, &["push", "-q", &url, "main"]); s.git(&work, &["push", "-q", &s.url("bare"), "main"]); let dist = s.path("dist"); let write = |path: &str, bytes: &[u8]| { let at = dist.join(path); std::fs::create_dir_all(at.parent().unwrap()).unwrap(); std::fs::write(at, bytes).unwrap(); }; let script: String = (0..400).map(|i| format!("let a{i} = {i};\n")).collect(); write("index.html", b"

home

"); write("app.js", script.as_bytes()); write("docs/index.html", b"docs"); write("404.html", b"gone"); write("data.bin", b"\x00\x01"); let dir = dist.to_string_lossy().into_owned(); let deploy = ["repo", "site", &url, &dir, "--not-found", "404.html"]; let (code, out) = s.cli(&deploy, b""); assert_eq!(code, 0, "{out}"); let host = format!("{}.{DOMAIN}", handle.replace('-', "--").replace('.', "-")); let plc = did.strip_prefix("did:plc:").unwrap(); let by_did = format!("{plc}.{DOMAIN}"); wait_until(|| get(&base, &host, "/site/", &[]).0 == 200); // Its repository's pages name it, with nothing said of what it was // deployed from; a repository with none names none. let shown = |name: &str| -> serde_json::Value { let repo = format!("at://{did}/dev.knotless.repo/{name}"); let asked = format!("{base}/xrpc/dev.knotless.getRepo?repo={repo}"); let answer: serde_json::Value = reqwest::blocking::get(asked).unwrap().json().unwrap(); answer["site"].clone() }; let site = shown("site"); assert_eq!(site["url"], format!("https://{host}/site/"), "{site}"); assert!(site["deployedAt"].is_string(), "{site}"); assert!(site.get("commit").is_none() && site.get("check").is_none()); assert!(shown("bare").is_null()); // A page, as its record says, with its hash as its ETag. let (status, headers, body) = get(&base, &host, "/site/", &[]); assert_eq!((status, body.as_str()), (200, "

home

")); assert_eq!(headers["content-type"], "text/html; charset=utf-8"); assert_eq!(headers["x-content-type-options"], "nosniff"); assert_eq!(headers["cache-control"], "public, max-age=60"); let etag = headers["etag"].to_str().unwrap().to_string(); let (status, _, body) = get(&base, &host, "/site/", &[("if-none-match", &etag)]); assert_eq!((status, body.as_str()), (304, "")); // Joined from its parts, and typed by its extension, or as bytes. let (status, headers, body) = get(&base, &by_did, "/site/app.js", &[]); assert_eq!((status, body), (200, script)); assert_eq!(headers["content-type"], "text/javascript; charset=utf-8"); let (_, headers, _) = get(&base, &host, "/site/data.bin", &[]); assert_eq!(headers["content-type"], "application/octet-stream"); // Directories, with and without their `/`. let location = |path: &str, host: &str| { let (status, headers, _) = get(&base, host, path, &[]); assert_eq!(status, 302, "{path}"); headers["location"].to_str().unwrap().to_string() }; assert_eq!(location("/site", &host), "/site/"); assert_eq!(location("/site/docs", &host), "docs/"); assert_eq!(get(&base, &host, "/site/docs/", &[]).2, "docs"); // The owner's root is their page. assert_eq!(location("/", &host), format!("{HOME}/{handle}")); assert_eq!(location("/", &by_did), format!("{HOME}/{did}")); // What is not there: the site's 404 page, or a plain one. let (status, _, body) = get(&base, &host, "/site/nope.js", &[]); assert_eq!((status, body.as_str()), (404, "gone")); for (host, path) in [ (host.as_str(), "/bare/"), (host.as_str(), "/nothing/"), (host.as_str(), "/site/%ff"), // Not redirected to `/\evil.example/`, which a browser reads as // another host. (host.as_str(), "/%5Cevil.example"), ("short.knotless.page", "/site/"), ("nobody-test.knotless.page", "/site/"), ] { let (status, _, body) = get(&base, host, path, &[]); assert_eq!( (status, body.as_str()), (404, "no such site\n"), "{host}{path}" ); } // Only reads, and only on its domain: anything else is the server's. let res = reqwest::blocking::Client::new() .post(format!("{base}/site/")) .header("x-forwarded-host", &host) .send() .unwrap(); assert_eq!(res.status().as_u16(), 405); assert_eq!(res.headers()["allow"], "GET, HEAD"); assert_eq!(get(&base, "localhost", "/healthz", &[]).2, "ok"); // Deployed again as a single-page app: its routes are its page, and a // script still missing is not. write("index.html", b"

app

"); let sha = "19fa13fcd6bd147877c93f9b1ac81a68b884d733"; let runner = "did:plc:runnerofsitestest2345abc"; let check = format!("at://{runner}/dev.knotless.check/built"); let again = [ "repo", "site", &url, &dir, "--fallback", "index.html", "--commit", sha, "--check", &check, ]; assert_eq!(s.cli(&again, b"").0, 0); wait_until(|| get(&base, &host, "/site/", &[]).2 == "

app

"); // Its commit as given, and its check only once the repository names // the runner it is from. wait_until(|| shown("site")["commit"] == sha); assert!(shown("site").get("check").is_none()); let (key, mut repo) = s .records("dev.knotless.repo") .into_iter() .find(|(_, r)| r["name"] == "site") .unwrap(); repo["runners"] = serde_json::json!([{ "did": runner }]); s.put_record("dev.knotless.repo", &key, repo); wait_until(|| shown("site")["check"] == check.as_str()); let (status, _, body) = get(&base, &host, "/site/settings/me", &[]); assert_eq!((status, body.as_str()), (200, "

app

")); assert_eq!(get(&base, &host, "/site/gone.js", &[]).0, 404); // A file that is not what its record says is not served. let (rkey, mut record) = s.records("dev.knotless.site").remove(0); let files = record["files"].as_array_mut().unwrap(); let docs = files .iter_mut() .find(|f| f["path"] == "docs/index.html") .unwrap(); docs["sha256"] = serde_json::json!("0".repeat(64)); s.put_record("dev.knotless.site", &rkey, record); wait_until(|| get(&base, &host, "/site/docs/", &[]).0 == 502); // The operator taking down its repository, the site's record or its // owner takes the site down with them, and restoring brings it back. let index = app.index.as_ref().unwrap(); let repo = format!("at://{did}/dev.knotless.repo/site"); let record = format!("at://{did}/dev.knotless.site/{rkey}"); for subject in [&repo, &record, &did] { let why = serde_json::json!({"subject": subject}); index.take_down(HOME, subject, &why, true).unwrap(); wait_until(|| get(&base, &host, "/site/", &[]).0 == 404); index.take_down(HOME, subject, &why, false).unwrap(); wait_until(|| get(&base, &host, "/site/", &[]).0 == 200); } // Taken down by its owner, it is gone, and its pages name none. assert_eq!(s.cli(&["repo", "site", &url, "--down"], b"").0, 0); wait_until(|| get(&base, &host, "/site/", &[]).0 == 404); wait_until(|| shown("site").is_null()); }