chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Private repositories have sites, read by their members alone
.changeset/private-sites.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# Private repositories have sites | ||
| ||
`knotless repo site` now deploys a private repository's site. It is kept in the repository's space and read only by its members, at a host of its own, `https://<repo key>-<plc id>.private.knotless.page/`. A member who opens it logs in at knotless.dev if they are not logged in already. Someone who is not a member gets a 404, and a member you remove stops reading it within a minute. | ||
crates/knotless/src/oauth.rs+1−1
| @@ -1000,7 +1000,7 @@ mod tests { | ||
// The PDS grants each type's collections, so they go unnamed. | ||
assert!(!SCOPE.contains("collection=")); | ||
assert!(SCOPE.ends_with(" space:dev.knotless.space?authority=*&manage=create&manage=update&manage=delete space:dev.knotless.orgSpace?authority=*&manage=create&manage=update&manage=delete")); | ||
assert!(SPELLED_OUT.contains("&collection=dev.knotless.rerun&collection=dev.knotless.orgMember&manage=create&manage=update&manage=delete")); | ||
assert!(SPELLED_OUT.contains("&collection=dev.knotless.rerun&collection=dev.knotless.orgMember&collection=dev.knotless.site&manage=create&manage=update&manage=delete")); | ||
assert!(SPELLED_OUT.ends_with(" space:dev.knotless.orgSpace?authority=*&collection=dev.knotless.orgMember&collection=dev.knotless.orgAcceptance&manage=create&manage=update&manage=delete")); | ||
assert!(SCOPE.contains(" repo:dev.knotless.pull ")); | ||
assert!(SCOPE.contains(" repo:dev.knotless.check ")); | ||
crates/knotless/src/site.rs+78−13
| @@ -1,7 +1,9 @@ | ||
//! Sites: a repository's static website, its files over blobs in its | ||
//! owner's account under one record keyed by the repository's key, | ||
//! replaced on each deploy, and served at `<owner>.knotless.page/<repo>/` | ||
//! (see docs/design/pages.md). | ||
//! replaced on each deploy, and served at `<owner>.knotless.page/<repo>/`; | ||
//! a private repository's in its space, at a host of its own, | ||
//! `<key>-<plc id>.private.knotless.page`, to its members alone (see | ||
//! docs/design/pages.md). | ||
| ||
use crate::env::Ctx; | ||
use crate::push; | ||
| @@ -117,6 +119,35 @@ pub fn address(label: &str, name: &str) -> String { | ||
format!("https://{label}.{DOMAIN}/{name}/") | ||
} | ||
| ||
/// The label under [`DOMAIN`] that private sites' hosts are each a label | ||
/// of, kept apart from owners' labels so none shares an origin. | ||
pub const PRIVATE: &str = "private"; | ||
| ||
/// The label of the host `did`'s private repository with `key` has its | ||
/// site at: the key, then the owner's plc id. None for an owner with no | ||
/// plc id. A key is a TID and a plc id has no `-`, so the label is read | ||
/// back whole ([`private_site`]). | ||
pub fn private_label(did: &str, key: &str) -> Option<String> { | ||
did_label(did).map(|plc| format!("{key}-{plc}")) | ||
} | ||
| ||
/// The owner and repository key a private site's `label` names, as | ||
/// [`private_label`] wrote it. | ||
pub fn private_site(label: &str) -> Option<(String, String)> { | ||
let (key, plc) = label.split_once('-')?; | ||
let base32 = |s: &str| { | ||
s.bytes() | ||
.all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b)) | ||
}; | ||
let named = key.len() == 13 && plc.len() == 24 && base32(key) && base32(plc); | ||
named.then(|| (format!("did:plc:{plc}"), key.to_string())) | ||
} | ||
| ||
/// The address of the private site at `label`. | ||
pub fn private_address(label: &str) -> String { | ||
format!("https://{label}.{PRIVATE}.{DOMAIN}/") | ||
} | ||
| ||
/// Whether `path` is a site's file's: `/`-separated, with no leading `/`, | ||
/// no empty, `.` or `..` segment, and at most [`MAX_PATH`] bytes. | ||
pub fn valid_path(path: &str) -> bool { | ||
| @@ -177,12 +208,10 @@ pub struct Deployed { | ||
pub address: String, | ||
} | ||
| ||
/// `url`'s repository, public and found, with its key. | ||
/// `url`'s repository, found, with its key: a private one's site is | ||
/// kept in its space, as its packs are. | ||
async fn open(ctx: &Ctx, url: &RepoUrl) -> Result<(Remote, String)> { | ||
let remote = Remote::open(ctx, url).await?; | ||
if remote.space.is_some() { | ||
bail!("{url}: a private repository's site is not published yet"); | ||
} | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
}; | ||
| @@ -190,8 +219,14 @@ async fn open(ctx: &Ctx, url: &RepoUrl) -> Result<(Remote, String)> { | ||
} | ||
| ||
/// Where `url`'s site is served: at its owner's handle, as `url` names | ||
/// them, or else at the DID of the account `remote` found. | ||
fn served_at(url: &RepoUrl, remote: &Remote) -> Result<String> { | ||
/// them, or else at the DID of the account `remote` found; a private | ||
/// one's, at its own host, named by `key` and that DID. | ||
fn served_at(url: &RepoUrl, remote: &Remote, key: &str) -> Result<String> { | ||
if remote.space.is_some() { | ||
let label = private_label(remote.did(), key); | ||
let none = format!("{url}: a private site's owner is named by a did:plc"); | ||
return label.map(|l| private_address(&l)).context(none); | ||
} | ||
let owner = Some(url.authority.as_str()).filter(|a| !a.starts_with("did:")); | ||
let named = owner.and_then(label); | ||
let label = named.or_else(|| did_label(remote.did()).map(String::from)); | ||
| @@ -223,7 +258,7 @@ pub async fn deploy(ctx: &Ctx, url: &RepoUrl, spec: Deploy) -> Result<Deployed> | ||
} | ||
} | ||
let (remote, key) = open(ctx, url).await?; | ||
let address = served_at(url, &remote)?; | ||
let address = served_at(url, &remote, &key)?; | ||
let live = remote.get_record::<SiteRecord>(nsid::SITE, &key).await?; | ||
let auth = session::ensure(ctx, &remote.identity).await?.auth(); | ||
let mut held: HashMap<String, Vec<ReleasePart>> = HashMap::new(); | ||
| @@ -270,6 +305,7 @@ pub async fn deploy(ctx: &Ctx, url: &RepoUrl, spec: Deploy) -> Result<Deployed> | ||
None => remote::create(nsid::SITE, &key, typed), | ||
}; | ||
remote.apply(vec![write], &auth).await?; | ||
shown(ctx, &remote, url).await; | ||
Ok(Deployed { | ||
uploaded, | ||
files: count, | ||
| @@ -289,15 +325,25 @@ pub async fn take_down(ctx: &Ctx, url: &RepoUrl) -> Result<()> { | ||
bail!("{url} has no site"); | ||
} | ||
let auth = session::ensure(ctx, &remote.identity).await?.auth(); | ||
remote | ||
.apply(vec![remote::delete(nsid::SITE, &key)], &auth) | ||
.await | ||
let deleted = remote::delete(nsid::SITE, &key); | ||
remote.apply(vec![deleted], &auth).await?; | ||
shown(ctx, &remote, url).await; | ||
Ok(()) | ||
} | ||
| ||
/// Shows the appview a private repository whose site changed, as nothing | ||
/// written in a space reaches the firehose: its site is served from what | ||
/// the appview syncs of it. | ||
async fn shown(ctx: &Ctx, remote: &Remote, url: &RepoUrl) { | ||
if remote.space.is_some() { | ||
crate::space::show_appview(ctx, remote.did(), &url.name).await; | ||
} | ||
} | ||
| ||
/// `url`'s live site, and where it is served, if it has one. | ||
pub async fn read(ctx: &Ctx, url: &RepoUrl) -> Result<Option<(SiteRecord, String)>> { | ||
let (remote, key) = open(ctx, url).await?; | ||
let address = served_at(url, &remote)?; | ||
let address = served_at(url, &remote, &key)?; | ||
let live = remote.get_record::<SiteRecord>(nsid::SITE, &key).await?; | ||
Ok(live.map(|l| (l.value, address))) | ||
} | ||
| @@ -338,6 +384,25 @@ mod tests { | ||
assert_eq!(did_label("did:plc:abc"), Some("abc")); | ||
assert_eq!(did_label("did:web:example.com"), None); | ||
assert_eq!(address("a-b", "site"), "https://a-b.knotless.page/site/"); | ||
// A private site's host, and back. | ||
let (plc, key) = ("abcdefghijklmnopqrstuvwx", "3lbqz7ywmz22k"); | ||
let did = format!("did:plc:{plc}"); | ||
let label = private_label(&did, key).unwrap(); | ||
assert_eq!(label, format!("{key}-{plc}")); | ||
assert_eq!(private_site(&label), Some((did, key.to_string()))); | ||
assert_eq!( | ||
private_address(&label), | ||
format!("https://{label}.private.knotless.page/") | ||
); | ||
assert_eq!(private_label("did:web:example.com", key), None); | ||
for not in [ | ||
"chadtmiller-com", | ||
"short-abcdefghijklmnopqrstuvwx", | ||
&format!("{key}-{plc}x"), | ||
"3LBQZ7YWMZ22K-abcdefghijklmnopqrstuvwx", | ||
] { | ||
assert_eq!(private_site(not), None, "{not}"); | ||
} | ||
} | ||
| ||
#[test] | ||
crates/knotless/tests/cli.rs+4−2
| @@ -1696,8 +1696,10 @@ fn sites() { | ||
if net().spaces { | ||
let hidden = s.url("hidden"); | ||
assert_eq!(s.cli(&["repo", "create", &hidden, "--private"], b"").0, 0); | ||
let (ok, _, err) = s.knotless(&["repo", "site", &hidden, &dir], b""); | ||
assert!(!ok && err.contains("private repository's site"), "{err}"); | ||
// A private repository's site has a host of its own, under private. | ||
let (ok, out, err) = s.knotless(&["repo", "site", &hidden, &dir], b""); | ||
assert!(ok, "{err}"); | ||
assert!(out.contains(".private.knotless.page/"), "{out}"); | ||
} | ||
} | ||
| ||
crates/knotless-serve/src/index.rs+48−11
| @@ -1263,13 +1263,14 @@ impl Index { | ||
], | ||
) | ||
} 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. | ||
// A repository's site, keyed by the repository's key, and a | ||
// private one's by its space too, as its URI names it: one that | ||
// is not a site's record is passed by, as is 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(); | ||
let kept = site.is_ok_and(hashed); | ||
db.execute( | ||
"INSERT OR REPLACE INTO site SELECT ?1, ?2, ?3, ?4 WHERE ?5", | ||
params![uri, did, rkey, text, kept], | ||
| @@ -1632,12 +1633,40 @@ impl Index { | ||
/// 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>> { | ||
self.site_at(&format!("at://{did}/{}/{key}", nsid::SITE)) | ||
} | ||
| ||
/// The site of `did`'s private repository `key`, in the space `space`, | ||
/// as [`Index::site`]. | ||
pub fn space_site( | ||
&self, | ||
space: &str, | ||
did: &str, | ||
key: &str, | ||
) -> rusqlite::Result<Option<String>> { | ||
self.site_at(&record_uri(space, did, nsid::SITE, key)) | ||
} | ||
| ||
/// The private repository `did` owns with `key`, by its name: none | ||
/// for one this index keeps no space of. | ||
pub fn private_repo(&self, did: &str, key: &str) -> rusqlite::Result<Option<String>> { | ||
let db = self.readers.take(&self.db); | ||
let mut stmt = | ||
db.prepare_cached("SELECT uri, name FROM repo WHERE did = ?1 AND key = ?2")?; | ||
let rows = stmt.query_map([did, key], uri_and_name)?; | ||
let private = | ||
|(uri, _): &(String, String)| AtUri::parse(uri).is_ok_and(|at| at.space.is_some()); | ||
let found = rows.filter_map(Result::ok).find(private); | ||
Ok(found.map(|(_, name)| name)) | ||
} | ||
| ||
/// The site whose record is `uri`, as [`Index::site`]. | ||
fn site_at(&self, uri: &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() | ||
stmt.query_row([uri], |row| row.get(0)).optional() | ||
} | ||
| ||
/// How many accounts starred `did`'s repository with `key`, leaving | ||
| @@ -4282,6 +4311,11 @@ fn org_rows( | ||
} | ||
} | ||
| ||
/// A row's first two columns, a repository's URI and its name. | ||
fn uri_and_name(row: &rusqlite::Row) -> rusqlite::Result<(String, String)> { | ||
Ok((row.get(0)?, row.get(1)?)) | ||
} | ||
| ||
fn secret_row(row: &rusqlite::Row) -> rusqlite::Result<Secret> { | ||
let refs: String = row.get(2)?; | ||
Ok(Secret { | ||
| @@ -7093,21 +7127,24 @@ mod tests { | ||
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. | ||
// Not a site's record: passed by. | ||
index | ||
.put("did:a", nsid::SITE, "j", &json!({"files": 1})) | ||
.unwrap(); | ||
let space = "ats://did:a/dev.knotless.space/s"; | ||
assert_eq!(index.site("did:a", "j").unwrap(), None); | ||
// One in a space is its private repository's, found there alone. | ||
let space = "at://did:a/space/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); | ||
assert_eq!(index.site("did:a", "i").unwrap(), None); | ||
let private = index.space_site(space, "did:a", "i").unwrap().unwrap(); | ||
assert_eq!(serde_json::from_str::<Value>(&private).unwrap(), site); | ||
// 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/login.rs+12−5
| @@ -154,13 +154,20 @@ pub fn viewer(app: &App, headers: &HeaderMap) -> Option<String> { | ||
fn held(app: &App, token: &str) -> Option<String> { | ||
let key = oauth::client_key(&app.ctx).ok()?; | ||
let claims: Claims = crypto::verify_jwt(token, &key.public()).ok()?; | ||
let ctx = ctx_for(app, &claims.did); | ||
let held = claims.kind == "web" && holds(app, &claims.did, claims.iat); | ||
held.then_some(claims.did) | ||
} | ||
| ||
/// Whether what was signed for `did` at `iat`, in microseconds since the | ||
/// epoch, still holds: they have logged in here since, are not taken | ||
/// down, and have not logged out since it was signed. | ||
pub fn holds(app: &App, did: &str, iat: u64) -> bool { | ||
let ctx = ctx_for(app, did); | ||
let out: u64 = std::fs::read_to_string(ctx.env.config_dir.join(LOGGED_OUT)) | ||
.ok() | ||
.and_then(|t| t.trim().parse().ok()) | ||
.unwrap_or(0); | ||
let held = claims.kind == "web" && claims.iat > out && !app.taken_down(&claims.did); | ||
(held && session::load(&ctx, &claims.did).is_some()).then_some(claims.did) | ||
iat > out && !app.taken_down(did) && session::load(&ctx, did).is_some() | ||
} | ||
| ||
/// The other accounts a request's cookies hold, with their tokens, the one | ||
| @@ -203,7 +210,7 @@ fn set_others(gw: &Gateway, others: &[(String, String)]) -> HeaderValue { | ||
} | ||
| ||
/// The value of the cookie `name` the request holds. | ||
fn cookie(headers: &HeaderMap, name: &str) -> Option<String> { | ||
pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> { | ||
let all = headers.get_all(header::COOKIE).into_iter(); | ||
let pairs = all | ||
.filter_map(|v| v.to_str().ok()) | ||
| @@ -292,7 +299,7 @@ fn cookie_for(gw: &Gateway, cookie: &str) -> HeaderValue { | ||
} | ||
| ||
/// A page to go back to: a path of this site, else its front page. | ||
fn back_to(back: Option<&str>) -> String { | ||
pub fn back_to(back: Option<&str>) -> String { | ||
match back { | ||
Some(b) if b.starts_with('/') && !b.starts_with("//") && !b.contains('\\') => b.into(), | ||
_ => "/".into(), | ||
crates/knotless-serve/src/sites.rs+371−30
401 lines changed: not loaded until asked for.
crates/knotless-serve/src/spaces.rs+3−1
| @@ -45,7 +45,7 @@ use std::time::{Duration, Instant}; | ||
/// indexes them from the firehose for public ones; and an organization's | ||
/// records, as its space and its private repositories' spaces keep them, | ||
/// naming its invitations and hidden members. | ||
const COLLECTIONS: [&str; 18] = [ | ||
const COLLECTIONS: [&str; 19] = [ | ||
nsid::REPO, | ||
nsid::REDIRECT, | ||
nsid::REF, | ||
| @@ -64,6 +64,8 @@ const COLLECTIONS: [&str; 18] = [ | ||
nsid::ORG, | ||
nsid::ORG_MEMBER, | ||
nsid::ORG_ACCEPTANCE, | ||
// A private repository's site, served to its members. | ||
nsid::SITE, | ||
]; | ||
| ||
/// How long a space may go unsynced before a member reading it syncs it | ||
crates/knotless-serve/tests/pages.rs+230−1
| @@ -7,6 +7,7 @@ mod common; | ||
use common::*; | ||
| ||
use knotless_serve::gateway::Gateway; | ||
use knotless_serve::sites::{Sites, hosted}; | ||
use knotless_serve::{App, router}; | ||
use serde_json::{Value, json}; | ||
use std::sync::Arc; | ||
| @@ -46,7 +47,9 @@ fn server() -> Server { | ||
let did = gw.did.clone(); | ||
let index = knotless_serve::index::Index::open(&s.path("index.db")).unwrap(); | ||
let app = App::build(ctx(&s), Duration::ZERO, Some(gw), Some(index)); | ||
let router = router(app.clone()); | ||
// Repositories' sites, on their domain, as any host the proxy says. | ||
let sites = Sites::new(app.clone(), "knotless.page".into(), format!("{base}/")); | ||
let router = hosted(router(app.clone()), sites); | ||
rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); | ||
let firehose = net().pds.replace("http://", "ws://"); | ||
rt.spawn(knotless_serve::firehose::watch(app.clone(), firehose)); | ||
| @@ -1248,3 +1251,229 @@ fn an_organization_from_the_pages() { | ||
assert_eq!(viewer(o).get("organization"), None); | ||
assert_eq!(roster(None)["organization"], false); | ||
} | ||
| ||
/// What a request to `server` as the site at `host` got: its status, where | ||
/// it goes, the cookie it sets, how it may be cached, and its body. | ||
struct Visit { | ||
status: u16, | ||
location: String, | ||
cookie: Option<String>, | ||
cache: String, | ||
body: String, | ||
} | ||
| ||
fn visit(server: &Server, host: &str, path: &str, cookie: Option<&str>) -> Visit { | ||
let client = reqwest::blocking::Client::builder() | ||
.redirect(reqwest::redirect::Policy::none()) | ||
.build() | ||
.unwrap(); | ||
let mut req = client.get(format!("{}{path}", server.base)); | ||
req = req.header("x-forwarded-host", host); | ||
if let Some(cookie) = cookie { | ||
req = req.header("cookie", cookie); | ||
} | ||
let res = req.send().unwrap(); | ||
let header = |name: &str| { | ||
let value = res.headers().get(name); | ||
value.map(|v| v.to_str().unwrap().to_string()) | ||
}; | ||
let location = header("location").unwrap_or_default(); | ||
let cookie = header("set-cookie").map(|c| c.split(';').next().unwrap().to_string()); | ||
let cache = header("cache-control").unwrap_or_default(); | ||
Visit { | ||
status: res.status().as_u16(), | ||
location, | ||
cookie, | ||
cache, | ||
body: res.text().unwrap(), | ||
} | ||
} | ||
| ||
/// A private repository's site, deployed into its space and read by its | ||
/// members alone: sent from its host to the pages for a ticket, which | ||
/// they are given once logged in and proven a member, and let in by the | ||
/// pass the host trades it for. A stranger is given none; a member | ||
/// removed, or logged out, reads it no more. | ||
#[test] | ||
fn private_sites_are_read_by_members() { | ||
if !net().spaces { | ||
return; | ||
} | ||
let server = server(); | ||
let mut owner = person(account()); | ||
// The appview hears of a deploy, as nothing in a space reaches the | ||
// firehose. | ||
owner.set("KNOTLESS_APPVIEW", &server.base); | ||
let member = person(account()); | ||
let stranger = person(account()); | ||
let url = owner.url("hush"); | ||
assert_eq!(owner.cli(&["repo", "create", &url, "--private"], b"").0, 0); | ||
let work = owner.work("w", 1); | ||
owner.git(&work, &["push", "-q", &url, "main"]); | ||
let share = |undo: bool| { | ||
let mut share = vec!["repo", "share", &url, &member.account.handle]; | ||
if undo { | ||
share.push("--undo"); | ||
} | ||
owner.cli(&share, b"").0 | ||
}; | ||
assert_eq!(share(false), 0); | ||
let dist = owner.path("dist"); | ||
std::fs::create_dir_all(dist.join("docs")).unwrap(); | ||
std::fs::write(dist.join("index.html"), b"<h1>hushed</h1>").unwrap(); | ||
std::fs::write(dist.join("docs/index.html"), b"docs").unwrap(); | ||
let dir = dist.to_string_lossy().into_owned(); | ||
let (code, out) = owner.cli(&["repo", "site", &url, &dir], b""); | ||
assert_eq!(code, 0, "{out}"); | ||
// At a host of its own, named by its key and its owner's plc id. | ||
let address = out.lines().last().unwrap(); | ||
let host = address | ||
.strip_prefix("https://") | ||
.and_then(|a| a.strip_suffix("/")) | ||
.unwrap() | ||
.to_string(); | ||
let plc = owner.account.did.strip_prefix("did:plc:").unwrap(); | ||
assert!( | ||
host.ends_with(&format!("-{plc}.private.knotless.page")), | ||
"{host}" | ||
); | ||
// Its record is in the space, not among the owner's public ones. | ||
assert!(owner.records("dev.knotless.site").is_empty()); | ||
let (_, live) = owner.cli(&["repo", "site", &url], b""); | ||
assert!(live.starts_with(&format!("{address}\t2 files\t")), "{live}"); | ||
| ||
// No pass: sent to the pages for a ticket, back to the page asked. | ||
let first = visit(&server, &host, "/docs/?x=1", None); | ||
assert_eq!(first.status, 302); | ||
assert_eq!( | ||
first.location, | ||
format!( | ||
"{}/oauth/site?host={host}&to=%2Fdocs%2F%3Fx%3D1", | ||
server.base | ||
) | ||
); | ||
assert_eq!(first.cache, "private, no-store"); | ||
let tickets = first.location.strip_prefix(&server.base).unwrap(); | ||
// Logged in as no one, the pages ask for a login, and come back. | ||
let asked = visit(&server, "localhost", tickets, None); | ||
assert_eq!(asked.status, 302); | ||
assert!( | ||
asked.location.starts_with("/?login=%2Foauth%2Fsite%3F"), | ||
"{}", | ||
asked.location | ||
); | ||
| ||
// A member logged in is given a ticket for that host alone. | ||
let theirs = log_in(&server, &member.account); | ||
let given = visit(&server, "localhost", tickets, Some(&theirs)); | ||
assert_eq!(given.status, 302); | ||
let enter = format!("http://{host}/.knotless/enter?ticket="); | ||
assert!(given.location.starts_with(&enter), "{}", given.location); | ||
let enter = given | ||
.location | ||
.strip_prefix(&format!("http://{host}")) | ||
.unwrap(); | ||
assert!(enter.ends_with("&to=%2Fdocs%2F%3Fx%3D1"), "{enter}"); | ||
// Not taken on another site's host. | ||
let other_key = if host.starts_with('2') { "3" } else { "2" }; | ||
let elsewhere = format!("{other_key}{}", &host[1..]); | ||
let wrong = visit(&server, &elsewhere, enter, None); | ||
assert!( | ||
wrong.location.contains("/oauth/site?"), | ||
"{}", | ||
wrong.location | ||
); | ||
assert_eq!(wrong.cookie, None); | ||
// Traded on its own for a pass, once. | ||
let entered = visit(&server, &host, enter, None); | ||
assert_eq!( | ||
(entered.status, entered.location.as_str()), | ||
(302, "/docs/?x=1") | ||
); | ||
let pass = entered.cookie.unwrap(); | ||
assert!(pass.starts_with("knotless_site="), "{pass}"); | ||
let again = visit(&server, &host, enter, None); | ||
assert!( | ||
again.location.contains("/oauth/site?"), | ||
"{}", | ||
again.location | ||
); | ||
assert_eq!(again.cookie, None); | ||
| ||
// With it, the site, for them alone. | ||
let page = visit(&server, &host, "/", Some(&pass)); | ||
assert_eq!((page.status, page.body.as_str()), (200, "<h1>hushed</h1>")); | ||
assert_eq!(page.cache, "private, no-store"); | ||
assert_eq!(visit(&server, &host, "/docs/", Some(&pass)).body, "docs"); | ||
let moved = visit(&server, &host, "/docs", Some(&pass)); | ||
assert_eq!((moved.status, moved.location.as_str()), (302, "docs/")); | ||
assert_eq!(visit(&server, &host, "/gone.js", Some(&pass)).status, 404); | ||
assert_eq!(visit(&server, &host, "/%ff", Some(&pass)).status, 404); | ||
// The pass is the host's alone. | ||
let other = visit(&server, &elsewhere, "/", Some(&pass)); | ||
assert!( | ||
other.location.contains("/oauth/site?"), | ||
"{}", | ||
other.location | ||
); | ||
| ||
// A stranger logged in is given no ticket. | ||
let strangers = log_in(&server, &stranger.account); | ||
let refused = visit(&server, "localhost", tickets, Some(&strangers)); | ||
assert_eq!(refused.status, 404); | ||
// Nor is a host that names no private site's. | ||
let odd = "/oauth/site?host=nobody.knotless.page&to=%2F"; | ||
assert_eq!(visit(&server, "localhost", odd, Some(&theirs)).status, 404); | ||
| ||
// Removed, the member reads it no more once membership is asked | ||
// again; shared again, they do. | ||
assert_eq!(share(true), 0); | ||
server | ||
.app | ||
.reprove | ||
.store(0, std::sync::atomic::Ordering::Relaxed); | ||
assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 404); | ||
assert_eq!( | ||
visit(&server, "localhost", tickets, Some(&theirs)).status, | ||
404 | ||
); | ||
assert_eq!(share(false), 0); | ||
assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 200); | ||
| ||
// Redeployed, the new site is read once the space is synced, as | ||
// following it hears of the write. | ||
let space = knotless::space::uri(&owner.account.did, "hush"); | ||
let index = server.app.spaces.clone().unwrap(); | ||
let rt = tokio::runtime::Runtime::new().unwrap(); | ||
let synced = || { | ||
let c = ctx(&owner); | ||
rt.block_on(knotless_serve::spaces::sync(&c, &index, &space)) | ||
.unwrap(); | ||
}; | ||
std::fs::write(dist.join("index.html"), b"<h1>again</h1>").unwrap(); | ||
assert_eq!(owner.cli(&["repo", "site", &url, &dir], b"").0, 0); | ||
synced(); | ||
assert_eq!( | ||
visit(&server, &host, "/", Some(&pass)).body, | ||
"<h1>again</h1>" | ||
); | ||
| ||
// Logged out, their pass no longer holds. | ||
let logout = format!("{}/oauth/logout", server.base); | ||
assert_eq!(browse("POST", &logout, Some(&theirs), None).0, 200); | ||
let out = visit(&server, &host, "/", Some(&pass)); | ||
assert!(out.location.contains("/oauth/site?"), "{}", out.location); | ||
| ||
// Taken down by its owner, it is gone. | ||
let back = log_in(&server, &member.account); | ||
let given = visit(&server, "localhost", tickets, Some(&back)); | ||
let enter = given | ||
.location | ||
.strip_prefix(&format!("http://{host}")) | ||
.unwrap(); | ||
let pass = visit(&server, &host, enter, None).cookie.unwrap(); | ||
assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 200); | ||
assert_eq!(owner.cli(&["repo", "site", &url, "--down"], b"").0, 0); | ||
synced(); | ||
assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 404); | ||
} | ||
deploy/README.md+5−3
| @@ -152,8 +152,10 @@ knotless.dev { | ||
| ||
With `KNOTLESS_PAGES_DOMAIN` (`knotless.page`), the proxy answers | ||
repositories' sites too ([../docs/design/pages.md](../docs/design/pages.md)): | ||
the domain and every owner's label under it, on one certificate from the | ||
DNS challenge. That takes Caddy built with the `caddy-dns/cloudflare` | ||
the domain and every owner's label under it, and each private site's | ||
label under `private.` there, on wildcard certificates from the DNS | ||
challenge. The zone's `*` record covers `*.private` too, as nothing is | ||
at `private` itself. That takes Caddy built with the `caddy-dns/cloudflare` | ||
module, which `setup.sh` builds from a Dockerfile beside the compose file, | ||
and the zone's own Cloudflare token, Zone:Read and DNS:Edit on that zone | ||
alone. Give the token once, as `KNOTLESS_PAGES_DNS_TOKEN` in | ||
| @@ -162,7 +164,7 @@ alone. Give the token once, as `KNOTLESS_PAGES_DNS_TOKEN` in | ||
A run with nothing changed rebuilds and recreates nothing. | ||
| ||
```caddy | ||
knotless.page, *.knotless.page { | ||
knotless.page, *.knotless.page, *.private.knotless.page { | ||
tls { | ||
dns cloudflare {env.CLOUDFLARE_API_TOKEN} | ||
} | ||
deploy/setup.sh+5−4
| @@ -17,9 +17,10 @@ | ||
# - Caddy, in /etc/knotless-proxy, with a certificate for the domain from | ||
# HTTP-01 once its records point here, and www redirected to it; | ||
# - with KNOTLESS_PAGES_DOMAIN (knotless.page), repositories' sites there | ||
# too: Caddy built with the caddy-dns/cloudflare module, holding one | ||
# certificate for the domain and every name one label under it from the | ||
# DNS challenge, with the zone's own token, KNOTLESS_PAGES_DNS_TOKEN | ||
# too: Caddy built with the caddy-dns/cloudflare module, holding | ||
# certificates for the domain, every name one label under it, and every | ||
# private site's one label under private.<domain>, from the DNS | ||
# challenge, with the zone's own token, KNOTLESS_PAGES_DNS_TOKEN | ||
# (Zone:Read and DNS:Edit on that zone alone). The token is given once, | ||
# kept in /etc/knotless-proxy/cloudflare.env: a run without it keeps the | ||
# one there; | ||
| @@ -107,7 +108,7 @@ if [ -n "$PAGES" ]; then | ||
} | ||
cat >>/etc/knotless-proxy/Caddyfile <<EOF | ||
| ||
$PAGES, *.$PAGES { | ||
$PAGES, *.$PAGES, *.private.$PAGES { | ||
tls { | ||
dns cloudflare {env.CLOUDFLARE_API_TOKEN} | ||
} | ||