chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The proxy holds knotless.page's certificate, and serve answers its sites
.knotless/workflows/deploy.yml+4−0
| @@ -42,6 +42,10 @@ secrets: [DEPLOY_SSH_KEY, RELEASE_PASSWORD, RUNNER_HOST_SSH_KEY] | ||
env: | ||
KNOTLESS_HOST: root@knotless.dev | ||
KNOTLESS_DOMAIN: knotless.dev | ||
# Repositories' sites (docs/design/pages.md), whose certificate the | ||
# proxy holds once deploy/setup.sh has run with this and the zone's | ||
# token. | ||
KNOTLESS_PAGES_DOMAIN: knotless.page | ||
KNOTLESS_DATA: /mnt/data/knotless | ||
KNOTLESS_ADMIN_DIDS: did:plc:bcgltzqazw5tb6k2g3ttenbj | ||
# The runner's account (ci.knotless.dev), the triager's | ||
crates/knotless/src/jetstream.rs+4−3
| @@ -58,9 +58,9 @@ pub struct Op { | ||
| ||
/// Whether `collection` is one knotless writes, the forge reads, or a | ||
/// runner reports in, counts minutes in, or is asked in; a star, a | ||
/// follow, a release, a theme for the pages, or an organization's | ||
/// membership, on either side; a runner's key; or one an account's profile | ||
/// is made of. | ||
/// follow, a release, a site, a theme for the pages, or an | ||
/// organization's membership, on either side; a runner's key; or one an | ||
/// account's profile is made of. | ||
pub fn knotless(collection: &str) -> bool { | ||
nsid::ALL.contains(&collection) | ||
|| nsid::FORGE.contains(&collection) | ||
| @@ -72,6 +72,7 @@ pub fn knotless(collection: &str) -> bool { | ||
nsid::FOLLOW, | ||
nsid::REACTION, | ||
nsid::RELEASE, | ||
nsid::SITE, | ||
nsid::THEME, | ||
nsid::ORG, | ||
nsid::ORG_MEMBER, | ||
crates/knotless/src/release.rs+8−0
| @@ -101,6 +101,14 @@ pub fn sha256(bytes: &[u8]) -> String { | ||
format!("{:x}", Sha256::digest(bytes)) | ||
} | ||
| ||
/// Whether `hash` is one [`sha256`] gives: 64 lowercase hex digits. A | ||
/// record's hash names the file its bytes are kept in, so one that is not | ||
/// could name a path outside where they are kept. | ||
pub fn valid_sha256(hash: &str) -> bool { | ||
let hex = |b: u8| matches!(b, b'0'..=b'9' | b'a'..=b'f'); | ||
hash.len() == 64 && hash.bytes().all(hex) | ||
} | ||
| ||
/// Publishes `spec` as the owner of `url`'s repository, logged in: its | ||
/// files uploaded, then its record created. Returns the record's AT-URI. | ||
/// A version published before is refused, before anything is uploaded, | ||
crates/knotless-serve/src/index.rs+61−2
| @@ -13,8 +13,9 @@ use jiff::{Timestamp, tz::TimeZone}; | ||
use knotless::aturi::{AtUri, SpaceRef}; | ||
use knotless::forge::{Entry, Item}; | ||
use knotless::records::{ | ||
FailedCheck, OrgMemberRecord, OrgRecord, ReleaseRecord, RepoRecord, RepoRef, nsid, | ||
FailedCheck, OrgMemberRecord, OrgRecord, ReleaseRecord, RepoRecord, RepoRef, SiteRecord, nsid, | ||
}; | ||
use knotless::release::valid_sha256; | ||
use knotless::secrets::Secret; | ||
use knotless::usage::{Day, Network}; | ||
use rusqlite::functions::{Context, FunctionFlags}; | ||
| @@ -594,6 +595,8 @@ pub(crate) fn connect(path: &Path) -> rusqlite::Result<(Connection, bool)> { | ||
uri TEXT PRIMARY KEY, did TEXT NOT NULL, repo_key TEXT NOT NULL, | ||
created_at TEXT NOT NULL, value TEXT NOT NULL); | ||
CREATE INDEX IF NOT EXISTS release_repo ON release (did, repo_key, created_at); | ||
CREATE TABLE IF NOT EXISTS site ( | ||
uri TEXT PRIMARY KEY, did TEXT NOT NULL, repo_key TEXT NOT NULL, value TEXT NOT NULL); | ||
CREATE TABLE IF NOT EXISTS theme ( | ||
uri TEXT PRIMARY KEY, did TEXT NOT NULL, created_at TEXT NOT NULL, | ||
name TEXT NOT NULL, value TEXT NOT NULL); | ||
| @@ -773,7 +776,7 @@ const CACHE: &str = "DROP TABLE IF EXISTS repo; DROP TABLE IF EXISTS forge; | ||
DROP TABLE IF EXISTS profile_record; DROP TABLE IF EXISTS handle; | ||
DROP TABLE IF EXISTS home; | ||
DROP TABLE IF EXISTS star; DROP TABLE IF EXISTS follow; DROP TABLE IF EXISTS comment; | ||
DROP TABLE IF EXISTS reaction; DROP TABLE IF EXISTS release; DROP TABLE IF EXISTS mention; | ||
DROP TABLE IF EXISTS reaction; DROP TABLE IF EXISTS release; DROP TABLE IF EXISTS site; DROP TABLE IF EXISTS mention; | ||
DROP TABLE IF EXISTS reviewer; DROP TABLE IF EXISTS assigned; | ||
DROP TABLE IF EXISTS org; DROP TABLE IF EXISTS org_member; DROP TABLE IF EXISTS membership; | ||
DROP TABLE IF EXISTS collaborator; DROP TABLE IF EXISTS shared; | ||
| @@ -848,6 +851,7 @@ const FORGET_REPO: &[&str] = &[ | ||
"DELETE FROM forge WHERE repo_did = ?1 AND repo_key = ?2", | ||
"DELETE FROM check_run WHERE repo_did = ?1 AND repo_key = ?2", | ||
"DELETE FROM release WHERE did = ?1 AND repo_key = ?2", | ||
"DELETE FROM site WHERE uri = 'at://' || ?1 || '/dev.knotless.site/' || ?2", | ||
"DELETE FROM shared WHERE did = ?1 AND repo_key = ?2", | ||
"DELETE FROM shared_use WHERE did = ?1 AND key = ?2", | ||
"DELETE FROM star WHERE repo_did = ?1 AND repo_key = ?2", | ||
| @@ -894,6 +898,7 @@ const PURGE: &[&str] = &[ | ||
"DELETE FROM follow WHERE did = ?1", | ||
"DELETE FROM reaction WHERE did = ?1 -- scans: reaction, once, as an account is forgotten", | ||
"DELETE FROM release WHERE did = ?1", | ||
"DELETE FROM site WHERE uri >= ?2 AND uri < ?3", | ||
"DELETE FROM shared WHERE did = ?1", | ||
"DELETE FROM shared_use WHERE did = ?1", | ||
"DELETE FROM theme WHERE did = ?1", | ||
| @@ -1249,6 +1254,18 @@ impl Index { | ||
text | ||
], | ||
) | ||
} else if collection == nsid::SITE { | ||
// A public repository's site, keyed by the repository's key: | ||
// one that is not a site's record is passed by, as is one in a | ||
// space, which has no site yet, and one with a file whose hash | ||
// is not one, as the hash names the file it is kept in. | ||
let site = serde_json::from_value::<SiteRecord>(value.clone()); | ||
let hashed = |s: SiteRecord| s.files.iter().all(|f| valid_sha256(&f.sha256)); | ||
let kept = site.is_ok_and(hashed) && space.is_empty(); | ||
db.execute( | ||
"INSERT OR REPLACE INTO site SELECT ?1, ?2, ?3, ?4 WHERE ?5", | ||
params![uri, did, rkey, text, kept], | ||
) | ||
} else if collection == nsid::RELEASE { | ||
// Kept by the owner and the key of the repository it is of, as | ||
// its record names it: one that is not a release, by its names, | ||
| @@ -1518,6 +1535,7 @@ impl Index { | ||
db.execute("DELETE FROM minutes WHERE uri = ?1", [&uri])?; | ||
db.execute("DELETE FROM runner_key WHERE uri = ?1", [&uri])?; | ||
db.execute("DELETE FROM release WHERE uri = ?1", [&uri])?; | ||
db.execute("DELETE FROM site WHERE uri = ?1", [&uri])?; | ||
db.execute("DELETE FROM shared WHERE uri = ?1", [&uri])?; | ||
db.execute( | ||
"DELETE FROM profile_record WHERE did = ?1 AND collection = ?2 AND rkey = ?3", | ||
| @@ -1603,6 +1621,17 @@ impl Index { | ||
Ok(rows.filter_map(Result::ok).flatten().collect()) | ||
} | ||
| ||
/// The site of `did`'s repository `key`, as its record was last | ||
/// indexed: none when it has none, or it is taken down. | ||
pub fn site(&self, did: &str, key: &str) -> rusqlite::Result<Option<String>> { | ||
let db = self.readers.take(&self.db); | ||
let uri = format!("at://{did}/{}/{key}", nsid::SITE); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT value FROM site WHERE uri = ?1 AND uri NOT IN down AND did NOT IN down", | ||
)?; | ||
stmt.query_row([&uri], |row| row.get(0)).optional() | ||
} | ||
| ||
/// How many accounts starred `did`'s repository with `key`, leaving | ||
/// out those taken down. | ||
pub fn star_count(&self, did: &str, key: &str) -> rusqlite::Result<u64> { | ||
| @@ -6966,4 +6995,34 @@ mod tests { | ||
assert!(Index::open(&dir.path().join("i.db")).is_ok()); | ||
assert!(Index::open(dir.path()).is_err()); | ||
} | ||
| ||
#[test] | ||
fn sites_are_kept_by_their_repository() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let index = Index::open(&dir.path().join("i.db")).unwrap(); | ||
let file = | ||
|hash: &str| json!({"path": "index.html", "size": 1, "sha256": hash, "parts": []}); | ||
let hash = "0".repeat(64); | ||
let site = json!({"files": [file(&hash)], "createdAt": "2026-11-03T00:00:00Z"}); | ||
index.put("did:a", nsid::SITE, "k", &site).unwrap(); | ||
let kept = index.site("did:a", "k").unwrap().unwrap(); | ||
assert_eq!(serde_json::from_str::<Value>(&kept).unwrap(), site); | ||
// Not a site's record, or one in a space: passed by. | ||
index | ||
.put("did:a", nsid::SITE, "j", &json!({"files": 1})) | ||
.unwrap(); | ||
let space = "ats://did:a/dev.knotless.space/s"; | ||
index | ||
.put_from_space(space, "did:a", nsid::SITE, "i", &site) | ||
.unwrap(); | ||
assert_eq!(index.site("did:a", "j").unwrap(), None); | ||
// A hash that would name a path outside where files are kept. | ||
let out = format!("{:.<64}", "../../../../etc/passwd"); | ||
let bad = json!({"files": [file(&out)], "createdAt": "2026-11-03T00:00:00Z"}); | ||
index.put("did:a", nsid::SITE, "h", &bad).unwrap(); | ||
assert_eq!(index.site("did:a", "h").unwrap(), None); | ||
assert_eq!(index.site("did:a", "i").unwrap(), None); | ||
index.delete("did:a", nsid::SITE, "k").unwrap(); | ||
assert_eq!(index.site("did:a", "k").unwrap(), None); | ||
} | ||
} | ||
crates/knotless-serve/src/lib.rs+30−1
| @@ -41,6 +41,7 @@ pub mod private; | ||
pub mod releases; | ||
mod secrets; | ||
pub mod shared; | ||
pub mod sites; | ||
mod smart; | ||
pub mod spaces; | ||
pub mod status; | ||
| @@ -858,6 +859,7 @@ fn indexed_collections() -> impl Iterator<Item = &'static str> { | ||
knotless::records::nsid::FOLLOW, | ||
knotless::records::nsid::REACTION, | ||
knotless::records::nsid::RELEASE, | ||
knotless::records::nsid::SITE, | ||
knotless::records::nsid::THEME, | ||
knotless::records::nsid::ORG, | ||
knotless::records::nsid::ORG_MEMBER, | ||
| @@ -1303,6 +1305,9 @@ pub struct Options { | ||
pub oauth_url: Option<String>, | ||
/// Where code search keeps its Zoekt shards, if it is on. | ||
pub code_index: Option<std::path::PathBuf>, | ||
/// The domain repositories' sites are answered on, each owner at a | ||
/// label of it, as `knotless.page`: none answers none. | ||
pub pages_domain: Option<String>, | ||
} | ||
| ||
/// What `--private`, which is gone, says instead. | ||
| @@ -1335,6 +1340,7 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
sign_up_pds: gateway::SIGN_UP_PDS.into(), | ||
oauth_url: None, | ||
code_index: None, | ||
pages_domain: None, | ||
}; | ||
let mut it = args.iter().skip(1); | ||
while let Some(flag) = it.next() { | ||
| @@ -1353,6 +1359,7 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
"--firehose" => opts.firehose = Some(value()?).filter(|v| !v.is_empty()), | ||
"--jetstream" => opts.jetstream = Some(value()?).filter(|v| !v.is_empty()), | ||
"--public-url" => opts.public_url = Some(value()?), | ||
"--pages-domain" => opts.pages_domain = Some(value()?).filter(|v| !v.is_empty()), | ||
"--index" => opts.index = Some(value()?.into()), | ||
"--backfill" => opts.backfill = Some(value()?).filter(|v| !v.is_empty()), | ||
"--relay" => opts.relay = Some(value()?), | ||
| @@ -1406,7 +1413,7 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
"--code-index" => opts.code_index = Some(value()?.into()), | ||
other => { | ||
return Err(format!( | ||
"unexpected {other}\nusage: knotless-serve [--listen <addr>] [--ttl <secs>] [--firehose <wss://relay or pds>] [--jetstream <wss://host>] [--public-url <https://host>] [--oauth-url <https://host>] [--index <file>] [--backfill <https://pds or relay>] [--relay <https://relay>] [--labeler <did>]... [--default-runner <did>] [--client-minimum <version> [--client-minimum-reason <text>]] [--web <http://app>] [--releases <dir or knotless://repo>] [--stale <secs>] [--usage <file>] [--logs <file> [--logs-keep <lines>]] [--check-index <file>] [--metrics <addr>] [--bots <dir>] [--sign-up-pds <https://pds>] [--code-index <dir>]" | ||
"unexpected {other}\nusage: knotless-serve [--listen <addr>] [--ttl <secs>] [--firehose <wss://relay or pds>] [--jetstream <wss://host>] [--public-url <https://host>] [--oauth-url <https://host>] [--index <file>] [--backfill <https://pds or relay>] [--relay <https://relay>] [--labeler <did>]... [--default-runner <did>] [--client-minimum <version> [--client-minimum-reason <text>]] [--web <http://app>] [--releases <dir or knotless://repo>] [--stale <secs>] [--usage <file>] [--logs <file> [--logs-keep <lines>]] [--check-index <file>] [--metrics <addr>] [--bots <dir>] [--sign-up-pds <https://pds>] [--code-index <dir>] [--pages-domain <domain>]" | ||
)); | ||
} | ||
} | ||
| @@ -1440,6 +1447,11 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> { | ||
if opts.public_url.is_none() && opts.bots.is_some() { | ||
anyhow::bail!("--bots takes --public-url, where bots log in"); | ||
} | ||
if opts.public_url.is_none() && opts.pages_domain.is_some() { | ||
anyhow::bail!( | ||
"--pages-domain takes --public-url, where a site's owner is sent from its root" | ||
); | ||
} | ||
let gateway = match &opts.public_url { | ||
Some(url) => { | ||
let mut gw = gateway::Gateway::new(url).map_err(anyhow::Error::msg)?; | ||
| @@ -1538,13 +1550,20 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> { | ||
.map(|url| mcp_routes(mcp_ctx, url, index, addr, usage.clone())); | ||
let mcp = mcp.transpose()?.unwrap_or_default(); | ||
let registry = oci::routes(app.ctx.clone(), opts.public_url.clone()); | ||
let home = opts.public_url.clone().unwrap_or_default(); | ||
let releases = releases::routes(opts.releases, opts.public_url, app.ctx.clone()); | ||
let stopping = app.live.stopping.clone(); | ||
let sites = opts | ||
.pages_domain | ||
.map(|d| sites::Sites::new(app.clone(), d, home)); | ||
let mut routes = router(app) | ||
.merge(releases) | ||
.merge(registry) | ||
.merge(mcp) | ||
.merge(pages); | ||
if let Some(sites) = sites { | ||
routes = sites::hosted(routes, sites); | ||
} | ||
if let Some(usage) = usage.clone() { | ||
tokio::spawn(usage::flushing(usage.clone(), usage::FLUSH)); | ||
routes = usage::counted(routes, usage); | ||
| @@ -1706,4 +1725,14 @@ mod tests { | ||
let down = AppError::upstream(anyhow::anyhow!("resolver down")); | ||
assert_eq!(down.0, StatusCode::BAD_GATEWAY); | ||
} | ||
| ||
// A collection a backfill reads and the firehose passes by is indexed | ||
// once and never again: each list names it, or the index goes stale. | ||
#[test] | ||
fn the_firehose_keeps_every_indexed_collection() { | ||
for c in indexed_collections() { | ||
let kept = knotless::jetstream::knotless(c); | ||
assert!(kept, "{c} is backfilled but not kept live"); | ||
} | ||
} | ||
} | ||
crates/knotless-serve/src/plans.rs+2−1
| @@ -32,7 +32,7 @@ pub const ENV: &str = "KNOTLESS_PLANS"; | ||
/// The tables that grow with what people do, as many rows as prod might | ||
/// hold: a scan of one is a scan of the network. The rest are said to be | ||
/// small, as a scan of them is. | ||
pub const GROWING: [&str; 18] = [ | ||
pub const GROWING: [&str; 19] = [ | ||
"forge", | ||
"check_run", | ||
"comment", | ||
| @@ -45,6 +45,7 @@ pub const GROWING: [&str; 18] = [ | ||
"space_record", | ||
"profile_record", | ||
"release", | ||
"site", | ||
"reviewer", | ||
"assigned", | ||
"theme", | ||
crates/knotless-serve/src/sites.rsnew+476−0
476 lines changed: not loaded until asked for.
crates/knotless-serve/tests/serve.rs+13−0
| @@ -292,6 +292,14 @@ fn binary_lifecycle() { | ||
.unwrap(); | ||
assert_eq!(out.status.code(), Some(1)); | ||
assert!(String::from_utf8_lossy(&out.stderr).contains("--public-url")); | ||
// So are a site's owners sent from its root. | ||
let out = s | ||
.command(exe, s.dir.path()) | ||
.args(["--pages-domain", "knotless.page"]) | ||
.output() | ||
.unwrap(); | ||
assert_eq!(out.status.code(), Some(1)); | ||
assert!(String::from_utf8_lossy(&out.stderr).contains("--pages-domain takes --public-url")); | ||
| ||
std::fs::create_dir_all(s.path("releases")).unwrap(); | ||
std::fs::write(s.path("releases/latest"), "0.1.0\n").unwrap(); | ||
| @@ -332,6 +340,8 @@ fn binary_lifecycle() { | ||
"100000", | ||
"--code-index", | ||
&s.path("code").to_string_lossy(), | ||
"--pages-domain", | ||
"knotless.page", | ||
"--client-minimum", | ||
"0.1.0", | ||
"--client-minimum-reason", | ||
| @@ -618,6 +628,9 @@ fn arguments() { | ||
assert_eq!(o.sign_up_pds, "https://pds.example"); | ||
assert!(args(&["--sign-up-pds"]).is_err()); | ||
assert_eq!(o.oauth_url, None); | ||
assert_eq!(o.pages_domain, None); | ||
let o = args(&["--pages-domain", "knotless.page"]).unwrap(); | ||
assert_eq!(o.pages_domain.as_deref(), Some("knotless.page")); | ||
let o = args(&["--oauth-url", "https://gateway.example"]).unwrap(); | ||
assert_eq!(o.oauth_url.as_deref(), Some("https://gateway.example")); | ||
} | ||
crates/knotless-serve/tests/sites.rsnew+168−0
| @@ -0,0 +1,168 @@ | ||
//! Repositories' websites, deployed with `knotless 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"<h1>home</h1>"); | ||
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 = ["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); | ||
| ||
// 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, "<h1>home</h1>")); | ||
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"<h1>app</h1>"); | ||
let again = ["site", &url, &dir, "--fallback", "index.html"]; | ||
assert_eq!(s.cli(&again, b"").0, 0); | ||
wait_until(|| get(&base, &host, "/site/", &[]).2 == "<h1>app</h1>"); | ||
let (status, _, body) = get(&base, &host, "/site/settings/me", &[]); | ||
assert_eq!((status, body.as_str()), (200, "<h1>app</h1>")); | ||
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); | ||
| ||
// Taken down, it is gone. | ||
assert_eq!(s.cli(&["site", &url, "--down"], b"").0, 0); | ||
wait_until(|| get(&base, &host, "/site/", &[]).0 == 404); | ||
} | ||
deploy/deploy.sh+1−0
| @@ -238,6 +238,7 @@ fi | ||
[ -z "${KNOTLESS_ADMIN_DIDS:-}" ] || echo "KNOTLESS_ADMIN_DIDS=$KNOTLESS_ADMIN_DIDS" | ||
[ -z "${KNOTLESS_BOT_DIDS:-}" ] || echo "KNOTLESS_BOT_DIDS=$KNOTLESS_BOT_DIDS" | ||
[ -z "${KNOTLESS_LOG:-}" ] || echo "KNOTLESS_LOG=$KNOTLESS_LOG" | ||
[ -z "${KNOTLESS_PAGES_DOMAIN:-}" ] || echo "KNOTLESS_PAGES_DOMAIN=$KNOTLESS_PAGES_DOMAIN" | ||
[ -z "$PROFILES" ] || echo "COMPOSE_PROFILES=$PROFILES" | ||
if [ -n "$TRIAGE_REPOS" ]; then | ||
echo "KNOTLESS_TRIAGE_REPOS=$TRIAGE_REPOS" | ||
deploy/docker-compose.yml+5−0
| @@ -66,6 +66,11 @@ services: | ||
# `knotless` package, which the release workflow publishes. | ||
- --releases | ||
- knotless://chadtmiller.com/knotless | ||
# Repositories' sites, on a domain of their own (knotless.page), when | ||
# KNOTLESS_PAGES_DOMAIN names one; setup.sh's Caddy holds its | ||
# certificate. Empty answers none. | ||
- --pages-domain | ||
- ${KNOTLESS_PAGES_DOMAIN:-} | ||
# /metrics and PUT /log, inside the container alone: read them | ||
# with docker exec (deploy/README.md, Logs). | ||
- --metrics | ||